Commit 4b44d0bc authored by Phil Hughes's avatar Phil Hughes

Merge branch 'ce-to-ee-2018-07-26' into 'master'

CE upstream - 2018-07-26 17:46 UTC

Closes gitlab-qa#303

See merge request gitlab-org/gitlab-ee!6679
parents 25cac8f4 454214ab
...@@ -572,13 +572,12 @@ setup-test-env: ...@@ -572,13 +572,12 @@ setup-test-env:
- vendor/gitaly-ruby - vendor/gitaly-ruby
danger-review: danger-review:
image: registry.gitlab.com/gitlab-org/gitaly/dangercontainer:latest image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger
stage: test stage: test
allow_failure: true allow_failure: true
before_script:
- source scripts/utils.sh
- retry gem install danger --no-ri --no-rdoc
cache: {} cache: {}
dependencies: []
before_script: []
only: only:
variables: variables:
- $DANGER_GITLAB_API_TOKEN - $DANGER_GITLAB_API_TOKEN
......
...@@ -402,7 +402,8 @@ GEM ...@@ -402,7 +402,8 @@ GEM
googleauth (>= 0.5.1, < 0.7) googleauth (>= 0.5.1, < 0.7)
gssapi (1.2.0) gssapi (1.2.0)
ffi (>= 1.0.1) ffi (>= 1.0.1)
haml (4.0.7) haml (5.0.4)
temple (>= 0.8.0)
tilt tilt
haml_lint (0.26.0) haml_lint (0.26.0)
haml (>= 4.0, < 5.1) haml (>= 4.0, < 5.1)
......
...@@ -3,6 +3,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; ...@@ -3,6 +3,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { pluralize } from '~/lib/utils/text_utility'; import { pluralize } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { getCommitIconMap } from '../utils';
export default { export default {
components: { components: {
...@@ -34,16 +35,14 @@ export default { ...@@ -34,16 +35,14 @@ export default {
}, },
computed: { computed: {
changedIcon() { changedIcon() {
const suffix = this.file.staged && !this.showStagedIcon ? '-solid' : ''; const suffix = !this.file.changed && this.file.staged && !this.showStagedIcon ? '-solid' : '';
return this.file.tempFile && !this.forceModifiedIcon
? `file-addition${suffix}` if (this.forceModifiedIcon) return `file-modified${suffix}`;
: `file-modified${suffix}`;
}, return `${getCommitIconMap(this.file).icon}${suffix}`;
stagedIcon() {
return `${this.changedIcon}-solid`;
}, },
changedIconClass() { changedIconClass() {
return `multi-${this.changedIcon} float-left`; return `ide-${this.changedIcon} float-left`;
}, },
tooltipTitle() { tooltipTitle() {
if (!this.showTooltip) return undefined; if (!this.showTooltip) return undefined;
...@@ -66,6 +65,9 @@ export default { ...@@ -66,6 +65,9 @@ export default {
return undefined; return undefined;
}, },
showIcon() {
return this.file.changed || this.file.tempFile || this.file.staged || this.file.deleted;
},
}, },
}; };
</script> </script>
...@@ -79,7 +81,7 @@ export default { ...@@ -79,7 +81,7 @@ export default {
class="ide-file-changed-icon" class="ide-file-changed-icon"
> >
<icon <icon
v-if="file.changed || file.tempFile || file.staged" v-if="showIcon"
:name="changedIcon" :name="changedIcon"
:size="12" :size="12"
:css-classes="changedIconClass" :css-classes="changedIconClass"
......
...@@ -5,6 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -5,6 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import StageButton from './stage_button.vue'; import StageButton from './stage_button.vue';
import UnstageButton from './unstage_button.vue'; import UnstageButton from './unstage_button.vue';
import { viewerTypes } from '../../constants'; import { viewerTypes } from '../../constants';
import { getCommitIconMap } from '../../utils';
export default { export default {
components: { components: {
...@@ -42,11 +43,12 @@ export default { ...@@ -42,11 +43,12 @@ export default {
}, },
computed: { computed: {
iconName() { iconName() {
const prefix = this.stagedList ? '-solid' : ''; const suffix = this.stagedList ? '-solid' : '';
return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`;
return `${getCommitIconMap(this.file).icon}${suffix}`;
}, },
iconClass() { iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; return `${getCommitIconMap(this.file).class} append-right-8`;
}, },
fullKey() { fullKey() {
return `${this.keyPrefix}-${this.file.key}`; return `${this.keyPrefix}-${this.file.key}`;
...@@ -67,6 +69,8 @@ export default { ...@@ -67,6 +69,8 @@ export default {
'stageChange', 'stageChange',
]), ]),
openFileInEditor() { openFileInEditor() {
if (this.file.type === 'tree') return null;
return this.openPendingTab({ return this.openPendingTab({
file: this.file, file: this.file,
keyPrefix: this.keyPrefix, keyPrefix: this.keyPrefix,
......
...@@ -10,7 +10,7 @@ export default { ...@@ -10,7 +10,7 @@ export default {
EditorModeDropdown, EditorModeDropdown,
}, },
computed: { computed: {
...mapGetters(['currentMergeRequest']), ...mapGetters(['currentMergeRequest', 'activeFile']),
...mapState(['viewer', 'currentMergeRequestId']), ...mapState(['viewer', 'currentMergeRequestId']),
showLatestChangesText() { showLatestChangesText() {
return !this.currentMergeRequestId || this.viewer === viewerTypes.diff; return !this.currentMergeRequestId || this.viewer === viewerTypes.diff;
...@@ -23,12 +23,20 @@ export default { ...@@ -23,12 +23,20 @@ export default {
}, },
}, },
mounted() { mounted() {
if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) {
this.$router.push(`/project${this.activeFile.url}`, () => {
this.updateViewer('editor');
});
} else if (this.activeFile && this.activeFile.deleted) {
this.resetOpenFiles();
}
this.$nextTick(() => { this.$nextTick(() => {
this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff); this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
}); });
}, },
methods: { methods: {
...mapActions(['updateViewer']), ...mapActions(['updateViewer', 'resetOpenFiles']),
}, },
}; };
</script> </script>
...@@ -36,7 +44,6 @@ export default { ...@@ -36,7 +44,6 @@ export default {
<template> <template>
<ide-tree-list <ide-tree-list
:viewer-type="viewer" :viewer-type="viewer"
:disable-action-dropdown="true"
header-class="ide-review-header" header-class="ide-review-header"
> >
<template <template
......
...@@ -17,14 +17,18 @@ export default { ...@@ -17,14 +17,18 @@ export default {
...mapGetters(['currentProject', 'currentTree', 'activeFile']), ...mapGetters(['currentProject', 'currentTree', 'activeFile']),
}, },
mounted() { mounted() {
if (this.activeFile && this.activeFile.pending) { if (!this.activeFile) return;
if (this.activeFile.pending && !this.activeFile.deleted) {
this.$router.push(`/project${this.activeFile.url}`, () => { this.$router.push(`/project${this.activeFile.url}`, () => {
this.updateViewer('editor'); this.updateViewer('editor');
}); });
} else if (this.activeFile.deleted) {
this.resetOpenFiles();
} }
}, },
methods: { methods: {
...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry']), ...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry', 'resetOpenFiles']),
}, },
}; };
</script> </script>
......
...@@ -22,11 +22,6 @@ export default { ...@@ -22,11 +22,6 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
disableActionDropdown: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
...mapState(['currentBranchId']), ...mapState(['currentBranchId']),
...@@ -69,7 +64,6 @@ export default { ...@@ -69,7 +64,6 @@ export default {
:key="file.key" :key="file.key"
:file="file" :file="file"
:level="0" :level="0"
:disable-action-dropdown="disableActionDropdown"
/> />
</template> </template>
</div> </div>
......
...@@ -13,7 +13,7 @@ export default { ...@@ -13,7 +13,7 @@ export default {
ItemButton, ItemButton,
}, },
props: { props: {
branch: { type: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['createTempEntry', 'openNewEntryModal']), ...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']),
createNewItem(type) { createNewItem(type) {
this.openNewEntryModal({ type, path: this.path }); this.openNewEntryModal({ type, path: this.path });
this.dropdownOpen = false; this.dropdownOpen = false;
...@@ -82,6 +82,7 @@ export default { ...@@ -82,6 +82,7 @@ export default {
ref="dropdownMenu" ref="dropdownMenu"
class="dropdown-menu dropdown-menu-right" class="dropdown-menu dropdown-menu-right"
> >
<template v-if="type === 'tree'">
<li> <li>
<item-button <item-button
:label="__('New file')" :label="__('New file')"
...@@ -106,6 +107,17 @@ export default { ...@@ -106,6 +107,17 @@ export default {
@click="createNewItem('tree')" @click="createNewItem('tree')"
/> />
</li> </li>
<li class="divider"></li>
</template>
<li>
<item-button
:label="__('Delete')"
class="d-flex"
icon="remove"
icon-classes="mr-2"
@click="deleteEntry(path)"
/>
</li>
</ul> </ul>
</div> </div>
</div> </div>
......
...@@ -44,7 +44,7 @@ export default { ...@@ -44,7 +44,7 @@ export default {
}, },
}, },
mounted() { mounted() {
if (this.lastOpenedFile) { if (this.lastOpenedFile && this.lastOpenedFile.type !== 'tree') {
this.openPendingTab({ this.openPendingTab({
file: this.lastOpenedFile, file: this.lastOpenedFile,
keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged, keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged,
......
...@@ -87,7 +87,9 @@ export default { ...@@ -87,7 +87,9 @@ export default {
this.editor.updateDimensions(); this.editor.updateDimensions();
}, },
viewer() { viewer() {
if (!this.file.pending) {
this.createEditorInstance(); this.createEditorInstance();
}
}, },
panelResizing() { panelResizing() {
if (!this.panelResizing) { if (!this.panelResizing) {
...@@ -109,6 +111,7 @@ export default { ...@@ -109,6 +111,7 @@ export default {
}, },
methods: { methods: {
...mapActions([ ...mapActions([
'getFileData',
'getRawFileData', 'getRawFileData',
'changeFileContent', 'changeFileContent',
'setFileLanguage', 'setFileLanguage',
...@@ -123,10 +126,16 @@ export default { ...@@ -123,10 +126,16 @@ export default {
this.editor.clearEditor(); this.editor.clearEditor();
this.getFileData({
path: this.file.path,
makeFileActive: false,
})
.then(() =>
this.getRawFileData({ this.getRawFileData({
path: this.file.path, path: this.file.path,
baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '', baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '',
}) }),
)
.then(() => { .then(() => {
this.createEditorInstance(); this.createEditorInstance();
}) })
...@@ -246,6 +255,8 @@ export default { ...@@ -246,6 +255,8 @@ export default {
ref="editor" ref="editor"
:class="{ :class="{
'is-readonly': isCommitModeActive, 'is-readonly': isCommitModeActive,
'is-deleted': file.deleted,
'is-added': file.tempFile
}" }"
class="multi-file-editor-holder" class="multi-file-editor-holder"
> >
......
...@@ -34,11 +34,6 @@ export default { ...@@ -34,11 +34,6 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
disableActionDropdown: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -212,8 +207,7 @@ export default { ...@@ -212,8 +207,7 @@ export default {
/> />
</span> </span>
<new-dropdown <new-dropdown
v-if="isTree && !disableActionDropdown" :type="file.type"
:project-id="file.projectId"
:branch="file.branchId" :branch="file.branchId"
:path="file.path" :path="file.path"
:mouse-over="mouseOver" :mouse-over="mouseOver"
......
...@@ -37,7 +37,7 @@ export default { ...@@ -37,7 +37,7 @@ export default {
return this.fileHasChanged ? !this.tabMouseOver : false; return this.fileHasChanged ? !this.tabMouseOver : false;
}, },
fileHasChanged() { fileHasChanged() {
return this.tab.changed || this.tab.tempFile || this.tab.staged; return this.tab.changed || this.tab.tempFile || this.tab.staged || this.tab.deleted;
}, },
}, },
...@@ -71,7 +71,8 @@ export default { ...@@ -71,7 +71,8 @@ export default {
<template> <template>
<li <li
:class="{ :class="{
active: tab.active active: tab.active,
disabled: tab.pending
}" }"
@click="clickFile(tab)" @click="clickFile(tab)"
@mouseover="mouseOverTab" @mouseover="mouseOverTab"
...@@ -105,7 +106,6 @@ export default { ...@@ -105,7 +106,6 @@ export default {
<changed-file-icon <changed-file-icon
v-else v-else
:file="tab" :file="tab"
:force-modified-icon="true"
/> />
</button> </button>
</li> </li>
......
...@@ -38,3 +38,18 @@ export const stageKeys = { ...@@ -38,3 +38,18 @@ export const stageKeys = {
unstaged: 'unstaged', unstaged: 'unstaged',
staged: 'staged', staged: 'staged',
}; };
export const commitItemIconMap = {
addition: {
icon: 'file-addition',
class: 'ide-file-addition',
},
modified: {
icon: 'file-modified',
class: 'ide-file-modified',
},
deleted: {
icon: 'file-deletion',
class: 'ide-file-deletion',
},
};
...@@ -7,7 +7,7 @@ export default class Model { ...@@ -7,7 +7,7 @@ export default class Model {
this.disposable = new Disposable(); this.disposable = new Disposable();
this.file = file; this.file = file;
this.head = head; this.head = head;
this.content = file.content !== '' ? file.content : file.raw; this.content = file.content !== '' || file.deleted ? file.content : file.raw;
this.disposable.add( this.disposable.add(
(this.originalModel = monacoEditor.createModel( (this.originalModel = monacoEditor.createModel(
......
...@@ -18,7 +18,7 @@ export default { ...@@ -18,7 +18,7 @@ export default {
return axios return axios
.get(file.rawPath, { .get(file.rawPath, {
params: { format: 'json' }, transformResponse: [f => f],
}) })
.then(({ data }) => data); .then(({ data }) => data);
}, },
...@@ -33,7 +33,7 @@ export default { ...@@ -33,7 +33,7 @@ export default {
return axios return axios
.get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), {
params: { format: 'json' }, transformResponse: [f => f],
}) })
.then(({ data }) => data); .then(({ data }) => data);
}, },
......
...@@ -185,6 +185,14 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => { ...@@ -185,6 +185,14 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
$('#ide-new-entry').modal('show'); $('#ide-new-entry').modal('show');
}; };
export const deleteEntry = ({ commit, dispatch, state }, path) => {
dispatch('burstUnusedSeal');
dispatch('closeFile', state.entries[path]);
commit(types.DELETE_ENTRY, path);
};
export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
export * from './actions/tree'; export * from './actions/tree';
export * from './actions/file'; export * from './actions/file';
export * from './actions/project'; export * from './actions/project';
......
...@@ -61,7 +61,11 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { ...@@ -61,7 +61,11 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => { export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => {
const file = state.entries[path]; const file = state.entries[path];
if (file.raw || file.tempFile) return Promise.resolve();
commit(types.TOGGLE_LOADING, { entry: file }); commit(types.TOGGLE_LOADING, { entry: file });
return service return service
.getFileData( .getFileData(
`${gon.relative_url_root ? gon.relative_url_root : ''}${file.url.replace('/-/', '/')}`, `${gon.relative_url_root ? gon.relative_url_root : ''}${file.url.replace('/-/', '/')}`,
...@@ -71,7 +75,7 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive ...@@ -71,7 +75,7 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive
setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE'])); setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE']));
commit(types.SET_FILE_DATA, { data, file }); commit(types.SET_FILE_DATA, { data, file });
commit(types.TOGGLE_FILE_OPEN, path); if (makeFileActive) commit(types.TOGGLE_FILE_OPEN, path);
if (makeFileActive) dispatch('setFileActive', path); if (makeFileActive) dispatch('setFileActive', path);
commit(types.TOGGLE_LOADING, { entry: file }); commit(types.TOGGLE_LOADING, { entry: file });
}) })
...@@ -97,7 +101,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) = ...@@ -97,7 +101,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) =
service service
.getRawFileData(file) .getRawFileData(file)
.then(raw => { .then(raw => {
commit(types.SET_FILE_RAW_DATA, { file, raw }); if (!file.tempFile) commit(types.SET_FILE_RAW_DATA, { file, raw });
if (file.mrChange && file.mrChange.new_file === false) { if (file.mrChange && file.mrChange.new_file === false) {
service service
.getBaseRawFileData(file, baseSha) .getBaseRawFileData(file, baseSha)
......
...@@ -21,14 +21,12 @@ export const showTreeEntry = ({ commit, dispatch, state }, path) => { ...@@ -21,14 +21,12 @@ export const showTreeEntry = ({ commit, dispatch, state }, path) => {
export const handleTreeEntryAction = ({ commit, dispatch }, row) => { export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
if (row.type === 'tree') { if (row.type === 'tree') {
dispatch('toggleTreeOpen', row.path); dispatch('toggleTreeOpen', row.path);
} else if (row.type === 'blob' && (row.opened || row.changed)) { } else if (row.type === 'blob') {
if (row.changed && !row.opened) { if (!row.opened) {
commit(types.TOGGLE_FILE_OPEN, row.path); commit(types.TOGGLE_FILE_OPEN, row.path);
} }
dispatch('setFileActive', row.path); dispatch('setFileActive', row.path);
} else {
dispatch('getFileData', { path: row.path });
} }
dispatch('showTreeEntry', row.path); dispatch('showTreeEntry', row.path);
......
...@@ -174,12 +174,14 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ...@@ -174,12 +174,14 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch('updateActivityBarView', activityBarViews.edit, { root: true }); dispatch('updateActivityBarView', activityBarViews.edit, { root: true });
dispatch('updateViewer', 'editor', { root: true }); dispatch('updateViewer', 'editor', { root: true });
if (rootGetters.activeFile) {
router.push( router.push(
`/project/${rootState.currentProjectId}/blob/${getters.branchName}/-/${ `/project/${rootState.currentProjectId}/blob/${getters.branchName}/-/${
rootGetters.activeFile.path rootGetters.activeFile.path
}`, }`,
); );
} }
}
}) })
.then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)) .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH))
.then(() => .then(() =>
......
import { sprintf, n__ } from '../../../../locale'; import { sprintf, n__, __ } from '../../../../locale';
import * as consts from './constants'; import * as consts from './constants';
const BRANCH_SUFFIX_COUNT = 5; const BRANCH_SUFFIX_COUNT = 5;
const createTranslatedTextForFiles = (files, text) => {
if (!files.length) return null;
return sprintf(n__('%{text} %{files}', '%{text} %{files} files', files.length), {
files: files.reduce((acc, val) => acc.concat(val.path), []).join(', '),
text,
});
};
export const discardDraftButtonDisabled = state => export const discardDraftButtonDisabled = state =>
state.commitMessage === '' || state.submitCommitLoading; state.commitMessage === '' || state.submitCommitLoading;
...@@ -29,14 +37,16 @@ export const branchName = (state, getters, rootState) => { ...@@ -29,14 +37,16 @@ export const branchName = (state, getters, rootState) => {
export const preBuiltCommitMessage = (state, _, rootState) => { export const preBuiltCommitMessage = (state, _, rootState) => {
if (state.commitMessage) return state.commitMessage; if (state.commitMessage) return state.commitMessage;
const files = (rootState.stagedFiles.length const files = rootState.stagedFiles.length ? rootState.stagedFiles : rootState.changedFiles;
? rootState.stagedFiles const modifiedFiles = files.filter(f => !f.deleted);
: rootState.changedFiles const deletedFiles = files.filter(f => f.deleted);
).reduce((acc, val) => acc.concat(val.path), []);
return sprintf(n__('Update %{files}', 'Update %{files} files', files.length), { return [
files: files.join(', '), createTranslatedTextForFiles(modifiedFiles, __('Update')),
}); createTranslatedTextForFiles(deletedFiles, __('Deleted')),
]
.filter(t => t)
.join('\n');
}; };
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
......
...@@ -76,3 +76,4 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES'; ...@@ -76,3 +76,4 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL'; export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';
export const DELETE_ENTRY = 'DELETE_ENTRY';
/* eslint-disable no-param-reassign */
import * as types from './mutation_types'; import * as types from './mutation_types';
import projectMutations from './mutations/project'; import projectMutations from './mutations/project';
import mergeRequestMutation from './mutations/merge_request'; import mergeRequestMutation from './mutations/merge_request';
...@@ -171,6 +172,16 @@ export default { ...@@ -171,6 +172,16 @@ export default {
newEntryModal: { type, path }, newEntryModal: { type, path },
}); });
}, },
[types.DELETE_ENTRY](state, path) {
const entry = state.entries[path];
const parent = entry.parentPath
? state.entries[entry.parentPath]
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
entry.deleted = true;
state.changedFiles = state.changedFiles.concat(entry);
parent.tree = parent.tree.filter(f => f.path !== entry.path);
},
...projectMutations, ...projectMutations,
...mergeRequestMutation, ...mergeRequestMutation,
...fileMutations, ...fileMutations,
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import { sortTree } from '../utils';
import { diffModes } from '../../constants'; import { diffModes } from '../../constants';
export default { export default {
...@@ -51,9 +52,17 @@ export default { ...@@ -51,9 +52,17 @@ export default {
}); });
}, },
[types.SET_FILE_RAW_DATA](state, { file, raw }) { [types.SET_FILE_RAW_DATA](state, { file, raw }) {
const openPendingFile = state.openFiles.find(
f => f.path === file.path && f.pending && !f.tempFile,
);
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
raw, raw,
}); });
if (openPendingFile) {
openPendingFile.raw = raw;
}
}, },
[types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) {
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
...@@ -109,11 +118,22 @@ export default { ...@@ -109,11 +118,22 @@ export default {
}, },
[types.DISCARD_FILE_CHANGES](state, path) { [types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path); const stagedFile = state.stagedFiles.find(f => f.path === path);
const entry = state.entries[path];
const { deleted } = entry;
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
content: stagedFile ? stagedFile.content : state.entries[path].raw, content: stagedFile ? stagedFile.content : state.entries[path].raw,
changed: false, changed: false,
deleted: false,
}); });
if (deleted) {
const parent = entry.parentPath
? state.entries[entry.parentPath]
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
parent.tree = sortTree(parent.tree.concat(entry));
}
}, },
[types.ADD_FILE_TO_CHANGED](state, path) { [types.ADD_FILE_TO_CHANGED](state, path) {
Object.assign(state, { Object.assign(state, {
......
...@@ -46,6 +46,7 @@ export const dataStructure = () => ({ ...@@ -46,6 +46,7 @@ export const dataStructure = () => ({
parentPath: null, parentPath: null,
lastOpenedAt: 0, lastOpenedAt: 0,
mrChange: null, mrChange: null,
deleted: false,
}); });
export const decorateData = entity => { export const decorateData = entity => {
...@@ -105,15 +106,37 @@ export const setPageTitle = title => { ...@@ -105,15 +106,37 @@ export const setPageTitle = title => {
document.title = title; document.title = title;
}; };
export const commitActionForFile = file => {
if (file.deleted) {
return 'delete';
} else if (file.tempFile) {
return 'create';
}
return 'update';
};
export const getCommitFiles = (stagedFiles, deleteTree = false) =>
stagedFiles.reduce((acc, file) => {
if ((file.deleted || deleteTree) && file.type === 'tree') {
return acc.concat(getCommitFiles(file.tree, true));
}
return acc.concat({
...file,
deleted: deleteTree || file.deleted,
});
}, []);
export const createCommitPayload = ({ branch, getters, newBranch, state, rootState }) => ({ export const createCommitPayload = ({ branch, getters, newBranch, state, rootState }) => ({
branch, branch,
commit_message: state.commitMessage || getters.preBuiltCommitMessage, commit_message: state.commitMessage || getters.preBuiltCommitMessage,
actions: rootState.stagedFiles.map(f => ({ actions: getCommitFiles(rootState.stagedFiles).map(f => ({
action: f.tempFile ? 'create' : 'update', action: commitActionForFile(f),
file_path: f.path, file_path: f.path,
content: f.content, content: f.content,
encoding: f.base64 ? 'base64' : 'text', encoding: f.base64 ? 'base64' : 'text',
last_commit_id: newBranch ? undefined : f.lastCommitSha, last_commit_id: newBranch || f.deleted ? undefined : f.lastCommitSha,
})), })),
start_branch: newBranch ? rootState.currentBranchId : undefined, start_branch: newBranch ? rootState.currentBranchId : undefined,
}); });
......
import { commitItemIconMap } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const getCommitIconMap = file => {
if (file.deleted) {
return commitItemIconMap.deleted;
} else if (file.tempFile) {
return commitItemIconMap.addition;
}
return commitItemIconMap.modified;
};
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
}, },
computed: { computed: {
className() { className() {
return `drag${this.side}`; return `drag-${this.side}`;
}, },
cursorStyle() { cursorStyle() {
if (this.enabled) { if (this.enabled) {
...@@ -44,8 +44,15 @@ ...@@ -44,8 +44,15 @@
methods: { methods: {
resetSize(e) { resetSize(e) {
e.preventDefault(); e.preventDefault();
this.$emit('resize-start', this.size);
this.size = this.startSize; this.size = this.startSize;
this.$emit('update:size', this.size); this.$emit('update:size', this.size);
// End resizing on next tick so that listeners can react to DOM changes
this.$nextTick(() => {
this.$emit('resize-end', this.size);
});
}, },
startDrag(e) { startDrag(e) {
if (this.enabled) { if (this.enabled) {
...@@ -84,7 +91,7 @@ ...@@ -84,7 +91,7 @@
<div <div
:class="className" :class="className"
:style="cursorStyle" :style="cursorStyle"
class="dragHandle" class="drag-handle"
@mousedown="startDrag" @mousedown="startDrag"
@dblclick="resetSize" @dblclick="resetSize"
></div> ></div>
......
...@@ -567,9 +567,6 @@ ...@@ -567,9 +567,6 @@
border-bottom: 1px solid $white-normal; border-bottom: 1px solid $white-normal;
.mx-auto { .mx-auto {
margin: 8px 0;
text-align: center;
.tanuki-logo, .tanuki-logo,
img { img {
height: 36px; height: 36px;
......
...@@ -874,3 +874,5 @@ $font-family-monospace: $monospace-font; ...@@ -874,3 +874,5 @@ $font-family-monospace: $monospace-font;
$input-line-height: 20px; $input-line-height: 20px;
$btn-line-height: 20px; $btn-line-height: 20px;
$table-accent-bg: $gray-light; $table-accent-bg: $gray-light;
$card-border-color: $border-color;
$card-cap-bg: $gray-light;
@import 'framework/variables';
@import 'framework/mixins';
.project-refs-form, .project-refs-form,
.project-refs-target-form { .project-refs-target-form {
display: inline-block; display: inline-block;
...@@ -74,6 +77,7 @@ ...@@ -74,6 +77,7 @@
.ide-file-icon-holder { .ide-file-icon-holder {
display: flex; display: flex;
align-items: center; align-items: center;
color: $theme-gray-700;
} }
.ide-file-changed-icon { .ide-file-changed-icon {
...@@ -161,12 +165,23 @@ ...@@ -161,12 +165,23 @@
background-color: $white-light; background-color: $white-light;
border-bottom-color: $white-light; border-bottom-color: $white-light;
} }
&:not(.disabled) {
.multi-file-tab {
cursor: pointer;
}
}
&.disabled {
.multi-file-tab-close {
cursor: default;
}
}
} }
} }
.multi-file-tab { .multi-file-tab {
@include str-truncated(141px); @include str-truncated(141px);
cursor: pointer;
svg { svg {
vertical-align: middle; vertical-align: middle;
...@@ -241,6 +256,38 @@ ...@@ -241,6 +256,38 @@
} }
} }
.is-deleted {
.editor.modified {
.margin-view-overlays,
.lines-content,
.decorationsOverviewRuler {
// !important to override monaco inline styles
display: none !important;
}
}
.diffOverviewRuler.modified {
// !important to override monaco inline styles
display: none !important;
}
}
.is-added {
.editor.original {
.margin-view-overlays,
.lines-content,
.decorationsOverviewRuler {
// !important to override monaco inline styles
display: none !important;
}
}
.diffOverviewRuler.original {
// !important to override monaco inline styles
display: none !important;
}
}
.monaco-diff-editor.vs { .monaco-diff-editor.vs {
.editor.modified { .editor.modified {
box-shadow: none; box-shadow: none;
...@@ -557,16 +604,21 @@ ...@@ -557,16 +604,21 @@
} }
} }
.multi-file-addition, .ide-file-addition,
.multi-file-addition-solid { .ide-file-addition-solid {
color: $green-500; color: $green-500;
} }
.multi-file-modified, .ide-file-modified,
.multi-file-modified-solid { .ide-file-modified-solid {
color: $orange-500; color: $orange-500;
} }
.ide-file-deletion,
.ide-file-deletion-solid {
color: $red-500;
}
.multi-file-commit-list-collapsed { .multi-file-commit-list-collapsed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -781,18 +833,21 @@ ...@@ -781,18 +833,21 @@
} }
} }
.dragHandle { .drag-handle {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 1px; width: 4px;
background-color: $white-dark;
&.dragright { &:hover {
background-color: $white-normal;
}
&.drag-right {
right: 0; right: 0;
} }
&.dragleft { &.drag-left {
left: 0; left: 0;
} }
} }
...@@ -1014,6 +1069,10 @@ ...@@ -1014,6 +1069,10 @@
.ide-new-btn { .ide-new-btn {
margin-left: auto; margin-left: auto;
} }
button {
color: $gl-text-color;
}
} }
.ide-sidebar-branch-title { .ide-sidebar-branch-title {
......
...@@ -199,6 +199,7 @@ ...@@ -199,6 +199,7 @@
.block { .block {
width: 100%; width: 100%;
word-break: break-word;
&:last-child { &:last-child {
border-bottom: 1px solid $border-gray-normal; border-bottom: 1px solid $border-gray-normal;
......
...@@ -4,13 +4,17 @@ class Projects::CommitsController < Projects::ApplicationController ...@@ -4,13 +4,17 @@ class Projects::CommitsController < Projects::ApplicationController
include ExtractsPath include ExtractsPath
include RendersCommits include RendersCommits
before_action :whitelist_query_limiting before_action :whitelist_query_limiting, except: :commits_root
before_action :require_non_empty_project before_action :require_non_empty_project
before_action :assign_ref_vars before_action :assign_ref_vars, except: :commits_root
before_action :authorize_download_code! before_action :authorize_download_code!
before_action :set_commits before_action :set_commits, except: :commits_root
before_action :set_request_format, only: :show before_action :set_request_format, only: :show
def commits_root
redirect_to project_commits_path(@project, @project.default_branch)
end
def show def show
@merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
.find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref) .find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref)
......
...@@ -7,5 +7,5 @@ class GitlabSchema < GraphQL::Schema ...@@ -7,5 +7,5 @@ class GitlabSchema < GraphQL::Schema
query(Types::QueryType) query(Types::QueryType)
default_max_page_size 100 default_max_page_size 100
# mutation(Types::MutationType) mutation(Types::MutationType)
end end
# frozen_string_literal: true
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
field :errors, [GraphQL::STRING_TYPE],
null: false,
description: "Reasons why the mutation failed."
def current_user
context[:current_user]
end
end
end
module Mutations
module ResolvesProject
extend ActiveSupport::Concern
def resolve_project(full_path:)
resolver.resolve(full_path: full_path)
end
def resolver
Resolvers::ProjectResolver.new(object: nil, context: context)
end
end
end
module Mutations
module MergeRequests
class Base < BaseMutation
include Gitlab::Graphql::Authorize::AuthorizeResource
include Mutations::ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: "The project the merge request to mutate is in"
argument :iid, GraphQL::ID_TYPE,
required: true,
description: "The iid of the merge request to mutate"
field :merge_request,
Types::MergeRequestType,
null: true,
description: "The merge request after mutation"
authorize :update_merge_request
private
def find_object(project_path:, iid:)
project = resolve_project(full_path: project_path)
resolver = Resolvers::MergeRequestResolver.new(object: project, context: context)
resolver.resolve(iid: iid)
end
end
end
end
# frozen_string_literal: true
module Mutations
module MergeRequests
class SetWip < Base
graphql_name 'MergeRequestSetWip'
argument :wip,
GraphQL::BOOLEAN_TYPE,
required: true,
description: <<~DESC
Whether or not to set the merge request as a WIP.
DESC
def resolve(project_path:, iid:, wip: nil)
merge_request = authorized_find!(project_path: project_path, iid: iid)
project = merge_request.project
::MergeRequests::UpdateService.new(project, current_user, wip_event: wip_event(merge_request, wip))
.execute(merge_request)
{
merge_request: merge_request,
errors: merge_request.errors.full_messages
}
end
private
def wip_event(merge_request, wip)
wip ? 'wip' : 'unwip'
end
end
end
end
# frozen_string_literal: true
module Types module Types
class MutationType < BaseObject class MutationType < BaseObject
include Gitlab::Graphql::MountMutation
graphql_name "Mutation" graphql_name "Mutation"
# TODO: Add Mutations as fields mount_mutation Mutations::MergeRequests::SetWip
end end
end end
...@@ -11,6 +11,7 @@ module EnvironmentsHelper ...@@ -11,6 +11,7 @@ module EnvironmentsHelper
{ {
"settings-path" => edit_project_service_path(project, 'prometheus'), "settings-path" => edit_project_service_path(project, 'prometheus'),
"clusters-path" => project_clusters_path(project), "clusters-path" => project_clusters_path(project),
"current-environment-name": environment.name,
"documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'), "documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'),
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'), "empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'), "empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'),
......
- @body_class = 'ide' - @body_class = 'ide'
- page_title 'IDE' - page_title 'IDE'
- content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/ide'
#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), #ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
......
...@@ -3,6 +3,11 @@ ...@@ -3,6 +3,11 @@
- site_name = "GitLab" - site_name = "GitLab"
%head{ prefix: "og: http://ogp.me/ns#" } %head{ prefix: "og: http://ogp.me/ns#" }
%meta{ charset: "utf-8" } %meta{ charset: "utf-8" }
- if Feature.enabled?('asset_host_prefetch') && ActionController::Base.asset_host
%link{ rel: 'dns-prefetch', href: ActionController::Base.asset_host }
%link{ rel: 'preconnnect', href: ActionController::Base.asset_host, crossorigin: '' }
%meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }
-# Open Graph - http://ogp.me/ -# Open Graph - http://ogp.me/
......
---
title: Redirect commits to root if no ref is provided (31576)
merge_request: 20738
author: Kia Mei Somabes
type: added
---
title: Increase width of Web IDE sidebar resize handles
merge_request: 20818
author:
type: fixed
---
title: Fix new MR card styles
merge_request: 20822
author:
type: fixed
---
title: DNS prefetching if asset_host for CDN hosting is set
merge_request: 20781
author:
type: performance
---
title: Add the first mutations for merge requests to GraphQL
merge_request: 20443
author:
type: added
---
title: Removes "show all" on reports and adds an actionButtons slot
merge_request: 20855
author:
type: changed
---
title: Enabled deletion of files in the Web IDE
merge_request:
author:
type: added
---
title: Fixed IDE not opening JSON files
merge_request: 20798
author:
type: fixed
---
title: Bump haml gem to 5.0.4
merge_request: 20847
author:
type: performance
---
title: Wrap job name on pipeline job sidebar
merge_request: 20804
author: George Tsiolis
type: changed
...@@ -48,7 +48,8 @@ module Gitlab ...@@ -48,7 +48,8 @@ module Gitlab
#{config.root}/app/services/concerns #{config.root}/app/services/concerns
#{config.root}/app/serializers/concerns #{config.root}/app/serializers/concerns
#{config.root}/app/finders/concerns #{config.root}/app/finders/concerns
#{config.root}/app/graphql/resolvers/concerns]) #{config.root}/app/graphql/resolvers/concerns
#{config.root}/app/graphql/mutations/concerns])
config.generators.templates.push("#{config.root}/generator_templates") config.generators.templates.push("#{config.root}/generator_templates")
...@@ -146,6 +147,7 @@ module Gitlab ...@@ -146,6 +147,7 @@ module Gitlab
config.assets.precompile << "notify.css" config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.css" config.assets.precompile << "mailers/*.css"
config.assets.precompile << "xterm/xterm.css" config.assets.precompile << "xterm/xterm.css"
config.assets.precompile << "page_bundles/ide.css"
config.assets.precompile << "performance_bar.css" config.assets.precompile << "performance_bar.css"
config.assets.precompile << "lib/ace.js" config.assets.precompile << "lib/ace.js"
config.assets.precompile << "test.css" config.assets.precompile << "test.css"
......
...@@ -90,6 +90,7 @@ scope format: false do ...@@ -90,6 +90,7 @@ scope format: false do
get '/raw/*id', to: 'raw#show', as: :raw get '/raw/*id', to: 'raw#show', as: :raw
get '/blame/*id', to: 'blame#show', as: :blame get '/blame/*id', to: 'blame#show', as: :blame
get '/commits', to: 'commits#commits_root', as: :commits_root
get '/commits/*id/signatures', to: 'commits#signatures', as: :signatures get '/commits/*id/signatures', to: 'commits#signatures', as: :signatures
get '/commits/*id', to: 'commits#show', as: :commits get '/commits/*id', to: 'commits#show', as: :commits
......
...@@ -41,8 +41,8 @@ end ...@@ -41,8 +41,8 @@ end
all_files = git.added_files + git.modified_files all_files = git.added_files + git.modified_files
non_geo_db_schema_updated = !git.modified_files.grep(%r{\Adb/schema\.rb/}).empty? non_geo_db_schema_updated = !git.modified_files.grep(%r{\Adb/schema\.rb}).empty?
geo_db_schema_updated = !git.modified_files.grep(%r{\Aee/db/geo/schema\.rb/}).empty? geo_db_schema_updated = !git.modified_files.grep(%r{\Aee/db/geo/schema\.rb}).empty?
non_geo_migration_created = !git.added_files.grep(%r{\A(db/(post_)?migrate)/}).empty? non_geo_migration_created = !git.added_files.grep(%r{\A(db/(post_)?migrate)/}).empty?
geo_migration_created = !git.added_files.grep(%r{\Aee/db/geo/(post_)?migrate/}).empty? geo_migration_created = !git.added_files.grep(%r{\Aee/db/geo/(post_)?migrate/}).empty?
......
...@@ -6,22 +6,21 @@ MAGIC_COMMENT = "# frozen_string_literal: true" ...@@ -6,22 +6,21 @@ MAGIC_COMMENT = "# frozen_string_literal: true"
def get_files_with_no_magic_comment(files) def get_files_with_no_magic_comment(files)
files.select do |file| files.select do |file|
file.end_with?(FILE_EXTENSION) && file.end_with?(FILE_EXTENSION) &&
!File.open(file, &:gets).start_with?(MAGIC_COMMENT) !File.open(file, &:gets)&.start_with?(MAGIC_COMMENT)
end end
end end
files_to_check = git.added_files files_to_fix = get_files_with_no_magic_comment(git.added_files)
files_to_fix = get_files_with_no_magic_comment(files_to_check)
if files_to_fix.any? if files_to_fix.any?
warn 'This merge request adds files that do not enforce frozen string literal. ' \ warn 'This merge request adds files that do not enforce frozen string literal. ' \
'See https://gitlab.com/gitlab-org/gitlab-ce/issues/47424 for more information.' 'See https://gitlab.com/gitlab-org/gitlab-ce/issues/47424 for more information.'
markdown(<<~MARKDOWN) markdown(<<~MARKDOWN)
## Enable Frozen String Literal ## Enable Frozen String Literal
The following files should have `#{MAGIC_COMMENT}` in the first line: The following files should have `#{MAGIC_COMMENT}` on the first line:
* #{files_to_fix.map { |path| "`#{path}`" }.join("\n* ")} * #{files_to_fix.map { |path| "`#{path}`" }.join("\n* ")}
MARKDOWN MARKDOWN
end end
...@@ -201,6 +201,148 @@ lot of dependant objects. ...@@ -201,6 +201,148 @@ lot of dependant objects.
To limit the amount of queries performed, we can use `BatchLoader`. To limit the amount of queries performed, we can use `BatchLoader`.
## Mutations
Mutations are used to change any stored values, or to trigger
actions. In the same way a GET-request should not modify data, we
cannot modify data in a regular GraphQL-query. We can however in a
mutation.
### Fields
In the most common situations, a mutation would return 2 fields:
- The resource being modified
- A list of errors explaining why the action could not be
performed. If the mutation succeeded, this list would be empty.
By inheriting any new mutations from `Mutations::BaseMutation` the
`errors` field is automatically added. A `clientMutationId` field is
also added, this can be used by the client to identify the result of a
single mutation when multiple are performed within a single request.
### Building Mutations
Mutations live in `app/graphql/mutations` ideally grouped per
resources they are mutating, similar to our services. They should
inherit `Mutations::BaseMutation`. The fields defined on the mutation
will be returned as the result of the mutation.
Always provide a consistent GraphQL-name to the mutation, this name is
used to generate the input types and the field the mutation is mounted
on. The name should look like `<Resource being modified><Mutation
class name>`, for example the `Mutations::MergeRequests::SetWip`
mutation has GraphQL name `MergeRequestSetWip`.
Arguments required by the mutation can be defined as arguments
required for a field. These will be wrapped up in an input type for
the mutation. For example, the `Mutations::MergeRequests::SetWip`
with GraphQL-name `MergeRequestSetWip` defines these arguments:
```ruby
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: "The project the merge request to mutate is in"
argument :iid, GraphQL::ID_TYPE,
required: true,
description: "The iid of the merge request to mutate"
argument :wip,
GraphQL::BOOLEAN_TYPE,
required: false,
description: <<~DESC
Whether or not to set the merge request as a WIP.
If not passed, the value will be toggled.
DESC
```
This would automatically generate an input type called
`MergeRequestSetWipInput` with the 3 arguments we specified and the
`clientMutationId`.
These arguments are then passed to the `resolve` method of a mutation
as keyword arguments. From here, we can call the service that will
modify the resource.
The `resolve` method should then return a hash with the same field
names as defined on the mutation and an `errors` array. For example,
the `Mutations::MergeRequests::SetWip` defines a `merge_request`
field:
```ruby
field :merge_request,
Types::MergeRequestType,
null: true,
description: "The merge request after mutation"
```
This means that the hash returned from `resolve` in this mutation
should look like this:
```ruby
{
# The merge request modified, this will be wrapped in the type
# defined on the field
merge_request: merge_request,
# An array if strings if the mutation failed after authorization
errors: merge_request.errors.full_messages
}
```
To make the mutation available it should be defined on the mutation
type that lives in `graphql/types/mutation_types`. The
`mount_mutation` helper method will define a field based on the
GraphQL-name of the mutation:
```ruby
module Types
class MutationType < BaseObject
include Gitlab::Graphql::MountMutation
graphql_name "Mutation"
mount_mutation Mutations::MergeRequests::SetWip
end
end
```
Will generate a field called `mergeRequestSetWip` that
`Mutations::MergeRequests::SetWip` to be resolved.
### Authorizing resources
To authorize resources inside a mutation, we can include the
`Gitlab::Graphql::Authorize::AuthorizeResource` concern in the
mutation.
This allows us to provide the required abilities on the mutation like
this:
```ruby
module Mutations
module MergeRequests
class SetWip < Base
graphql_name 'MergeRequestSetWip'
authorize :update_merge_request
end
end
end
```
We can then call `authorize!` in the `resolve` method, passing in the resource we
want to validate the abilities for.
Alternatively, we can add a `find_object` method that will load the
object on the mutation. This would allow you to use the
`authorized_find!` and `authorized_find!` helper methods.
When a user is not allowed to perform the action, or an object is not
found, we should raise a
`Gitlab::Graphql::Errors::ResourceNotAvailable` error. Which will be
correctly rendered to the clients.
## Testing ## Testing
_full stack_ tests for a graphql query or mutation live in _full stack_ tests for a graphql query or mutation live in
...@@ -212,3 +354,35 @@ be used to test if the query renders valid results. ...@@ -212,3 +354,35 @@ be used to test if the query renders valid results.
Using the `GraphqlHelpers#all_graphql_fields_for`-helper, a query Using the `GraphqlHelpers#all_graphql_fields_for`-helper, a query
including all available fields can be constructed. This makes it easy including all available fields can be constructed. This makes it easy
to add a test rendering all possible fields for a query. to add a test rendering all possible fields for a query.
To test GraphQL mutation requests, `GraphqlHelpers` provides 2
helpers: `graphql_mutation` which takes the name of the mutation, and
a hash with the input for the mutation. This will return a struct with
a mutation query, and prepared variables.
This struct can then be passed to the `post_graphql_mutation` helper,
that will post the request with the correct params, like a GraphQL
client would do.
To access the response of a mutation, the `graphql_mutation_response`
helper is available.
Using these helpers, we can build specs like this:
```ruby
let(:mutation) do
graphql_mutation(
:merge_request_set_wip,
project_path: 'gitlab-org/gitlab-ce',
iid: '1',
wip: true
)
end
it 'returns a successfull response' do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_mutation_response(:merge_request_set_wip)['errors']).to be_empty
end
```
...@@ -10,7 +10,14 @@ module Gitlab ...@@ -10,7 +10,14 @@ module Gitlab
end end
def required_permissions def required_permissions
@required_permissions ||= [] # If the `#authorize` call is used on multiple classes, we add the
# permissions specified on a subclass, to the ones that were specified
# on it's superclass.
@required_permissions ||= if self.respond_to?(:superclass) && superclass.respond_to?(:required_permissions)
superclass.required_permissions.dup
else
[]
end
end end
def authorize(*permissions) def authorize(*permissions)
......
module Gitlab
module Graphql
module Authorize
module AuthorizeResource
extend ActiveSupport::Concern
included do
extend Gitlab::Graphql::Authorize
end
def find_object(*args)
raise NotImplementedError, "Implement #find_object in #{self.class.name}"
end
def authorized_find(*args)
object = find_object(*args)
object if authorized?(object)
end
def authorized_find!(*args)
object = find_object(*args)
authorize!(object)
object
end
def authorize!(object)
unless authorized?(object)
raise Gitlab::Graphql::Errors::ResourceNotAvailable,
"The resource that you are attempting to access does not exist or you don't have permission to perform this action"
end
end
def authorized?(object)
self.class.required_permissions.all? do |ability|
# The actions could be performed across multiple objects. In which
# case the current user is common, and we could benefit from the
# caching in `DeclarativePolicy`.
Ability.allowed?(current_user, ability, object, scope: :user)
end
end
end
end
end
end
...@@ -3,6 +3,7 @@ module Gitlab ...@@ -3,6 +3,7 @@ module Gitlab
module Errors module Errors
BaseError = Class.new(GraphQL::ExecutionError) BaseError = Class.new(GraphQL::ExecutionError)
ArgumentError = Class.new(BaseError) ArgumentError = Class.new(BaseError)
ResourceNotAvailable = Class.new(BaseError)
end end
end end
end end
# frozen_string_literal: true
module Gitlab
module Graphql
module MountMutation
extend ActiveSupport::Concern
module ClassMethods
def mount_mutation(mutation_class)
# Using an underscored field name symbol will make `graphql-ruby`
# standardize the field name
field mutation_class.graphql_name.underscore.to_sym,
mutation: mutation_class
end
end
end
end
end
...@@ -146,6 +146,11 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts ...@@ -146,6 +146,11 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%{text} %{files}"
msgid_plural "%{text} %{files} files"
msgstr[0] ""
msgstr[1] ""
msgid "%{text} is available" msgid "%{text} is available"
msgstr "" msgstr ""
...@@ -2297,6 +2302,9 @@ msgstr "" ...@@ -2297,6 +2302,9 @@ msgstr ""
msgid "Delete list" msgid "Delete list"
msgstr "" msgstr ""
msgid "Deleted"
msgstr ""
msgid "Deny" msgid "Deny"
msgstr "" msgstr ""
...@@ -6708,10 +6716,8 @@ msgstr "" ...@@ -6708,10 +6716,8 @@ msgstr ""
msgid "Up to date" msgid "Up to date"
msgstr "" msgstr ""
msgid "Update %{files}" msgid "Update"
msgid_plural "Update %{files} files" msgstr ""
msgstr[0] ""
msgstr[1] ""
msgid "Update your group name, description, avatar, and other general settings." msgid "Update your group name, description, avatar, and other general settings."
msgstr "" msgstr ""
......
...@@ -72,6 +72,7 @@ module QA ...@@ -72,6 +72,7 @@ module QA
Capybara::Selenium::Driver.new( Capybara::Selenium::Driver.new(
app, app,
browser: :chrome, browser: :chrome,
clear_local_storage: true,
desired_capabilities: capabilities, desired_capabilities: capabilities,
options: options options: options
) )
......
...@@ -9,6 +9,18 @@ describe Projects::CommitsController do ...@@ -9,6 +9,18 @@ describe Projects::CommitsController do
project.add_maintainer(user) project.add_maintainer(user)
end end
describe "GET commits_root" do
context "no ref is provided" do
it 'should redirect to the default branch of the project' do
get(:commits_root,
namespace_id: project.namespace,
project_id: project)
expect(response).to redirect_to project_commits_path(project)
end
end
end
describe "GET show" do describe "GET show" do
render_views render_views
......
...@@ -18,8 +18,6 @@ describe GitlabSchema do ...@@ -18,8 +18,6 @@ describe GitlabSchema do
end end
it 'has the base mutation' do it 'has the base mutation' do
pending('Adding an empty mutation breaks the documentation explorer')
expect(described_class.mutation).to eq(::Types::MutationType.to_graphql) expect(described_class.mutation).to eq(::Types::MutationType.to_graphql)
end end
......
require 'spec_helper'
describe Mutations::ResolvesProject do
let(:mutation_class) do
Class.new(Mutations::BaseMutation) do
include Mutations::ResolvesProject
end
end
let(:context) { double }
subject(:mutation) { mutation_class.new(object: nil, context: context) }
it 'uses the ProjectsResolver to resolve projects by path' do
project = create(:project)
expect(Resolvers::ProjectResolver).to receive(:new).with(object: nil, context: context).and_call_original
expect(mutation.resolve_project(full_path: project.full_path)).to eq(project)
end
end
require 'spec_helper'
describe Mutations::MergeRequests::SetWip do
let(:merge_request) { create(:merge_request) }
let(:user) { create(:user) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
describe '#resolve' do
let(:wip) { true }
let(:mutated_merge_request) { subject[:merge_request] }
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, wip: wip) }
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when the user can update the merge request' do
before do
merge_request.project.add_developer(user)
end
it 'returns the merge request as a wip' do
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request).to be_work_in_progress
expect(subject[:errors]).to be_empty
end
it 'returns errors merge request could not be updated' do
# Make the merge request invalid
merge_request.allow_broken = true
merge_request.update!(source_project: nil)
expect(subject[:errors]).not_to be_empty
end
context 'when passing wip as false' do
let(:wip) { false }
it 'removes `wip` from the title' do
merge_request.update(title: "WIP: working on it")
expect(mutated_merge_request).not_to be_work_in_progress
end
it 'does not do anything if the title did not start with wip' do
expect(mutated_merge_request).not_to be_work_in_progress
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Types::MutationType do
it 'is expected to have the MergeRequestSetWip' do
expect(described_class).to have_graphql_mutation(Mutations::MergeRequests::SetWip)
end
end
...@@ -33,14 +33,14 @@ describe('IDE changed file icon', () => { ...@@ -33,14 +33,14 @@ describe('IDE changed file icon', () => {
}); });
describe('changedIconClass', () => { describe('changedIconClass', () => {
it('includes multi-file-modified when not a temp file', () => { it('includes ide-file-modified when not a temp file', () => {
expect(vm.changedIconClass).toContain('multi-file-modified'); expect(vm.changedIconClass).toContain('ide-file-modified');
}); });
it('includes multi-file-addition when a temp file', () => { it('includes ide-file-addition when a temp file', () => {
vm.file.tempFile = true; vm.file.tempFile = true;
expect(vm.changedIconClass).toContain('multi-file-addition'); expect(vm.changedIconClass).toContain('ide-file-addition');
}); });
}); });
}); });
...@@ -76,17 +76,29 @@ describe('Multi-file editor commit sidebar list item', () => { ...@@ -76,17 +76,29 @@ describe('Multi-file editor commit sidebar list item', () => {
expect(vm.iconName).toBe('file-addition'); expect(vm.iconName).toBe('file-addition');
}); });
it('returns deletion', () => {
f.deleted = true;
expect(vm.iconName).toBe('file-deletion');
});
}); });
describe('iconClass', () => { describe('iconClass', () => {
it('returns modified when not a tempFile', () => { it('returns modified when not a tempFile', () => {
expect(vm.iconClass).toContain('multi-file-modified'); expect(vm.iconClass).toContain('ide-file-modified');
}); });
it('returns addition when not a tempFile', () => { it('returns addition when not a tempFile', () => {
f.tempFile = true; f.tempFile = true;
expect(vm.iconClass).toContain('multi-file-addition'); expect(vm.iconClass).toContain('ide-file-addition');
});
it('returns deletion', () => {
f.deleted = true;
expect(vm.iconClass).toContain('ide-file-deletion');
}); });
}); });
}); });
......
...@@ -14,6 +14,7 @@ describe('new dropdown component', () => { ...@@ -14,6 +14,7 @@ describe('new dropdown component', () => {
branch: 'master', branch: 'master',
path: '', path: '',
mouseOver: false, mouseOver: false,
type: 'tree',
}); });
vm.$store.state.currentProjectId = 'abcproject'; vm.$store.state.currentProjectId = 'abcproject';
...@@ -67,4 +68,14 @@ describe('new dropdown component', () => { ...@@ -67,4 +68,14 @@ describe('new dropdown component', () => {
}); });
}); });
}); });
describe('delete entry', () => {
it('calls delete action', () => {
spyOn(vm, 'deleteEntry');
vm.$el.querySelectorAll('.dropdown-menu button')[3].click();
expect(vm.deleteEntry).toHaveBeenCalledWith('');
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import '~/behaviors/markdown/render_gfm';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import store from '~/ide/stores'; import store from '~/ide/stores';
import repoEditor from '~/ide/components/repo_editor.vue'; import repoEditor from '~/ide/components/repo_editor.vue';
...@@ -25,6 +26,8 @@ describe('RepoEditor', () => { ...@@ -25,6 +26,8 @@ describe('RepoEditor', () => {
vm.$store.state.openFiles.push(f); vm.$store.state.openFiles.push(f);
Vue.set(vm.$store.state.entries, f.path, f); Vue.set(vm.$store.state.entries, f.path, f);
spyOn(vm, 'getFileData').and.returnValue(Promise.resolve());
vm.$mount(); vm.$mount();
Vue.nextTick(() => setTimeout(done)); Vue.nextTick(() => setTimeout(done));
......
...@@ -91,25 +91,6 @@ describe('RepoFile', () => { ...@@ -91,25 +91,6 @@ describe('RepoFile', () => {
done(); done();
}); });
}); });
it('disables action dropdown', done => {
createComponent({
file: {
...file('t4'),
type: 'tree',
branchId: 'master',
projectId: 'project',
},
level: 0,
disableActionDropdown: true,
});
setTimeout(() => {
expect(vm.$el.querySelector('.ide-new-btn')).toBeNull();
done();
});
});
}); });
describe('locked file', () => { describe('locked file', () => {
......
...@@ -93,13 +93,13 @@ describe('RepoTab', () => { ...@@ -93,13 +93,13 @@ describe('RepoTab', () => {
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.$el.querySelector('.multi-file-modified')).toBeNull(); expect(vm.$el.querySelector('.ide-file-modified')).toBeNull();
vm.$el.dispatchEvent(new Event('mouseout')); vm.$el.dispatchEvent(new Event('mouseout'));
}) })
.then(Vue.nextTick) .then(Vue.nextTick)
.then(() => { .then(() => {
expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull(); expect(vm.$el.querySelector('.ide-file-modified')).not.toBeNull();
done(); done();
}) })
......
...@@ -366,6 +366,23 @@ describe('IDE store file actions', () => { ...@@ -366,6 +366,23 @@ describe('IDE store file actions', () => {
}); });
}); });
describe('return JSON', () => {
beforeEach(() => {
mock.onGet(/(.*)/).replyOnce(200, JSON.stringify({ test: '123' }));
});
it('does not parse returned JSON', done => {
store
.dispatch('getRawFileData', { path: tmpFile.path })
.then(() => {
expect(tmpFile.raw).toEqual('{"test":"123"}');
done();
})
.catch(done.fail);
});
});
describe('error', () => { describe('error', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet(/(.*)/).networkError(); mock.onGet(/(.*)/).networkError();
......
...@@ -7,6 +7,7 @@ import actions, { ...@@ -7,6 +7,7 @@ import actions, {
updateActivityBarView, updateActivityBarView,
updateTempFlagForEntry, updateTempFlagForEntry,
setErrorMessage, setErrorMessage,
deleteEntry,
} from '~/ide/stores/actions'; } from '~/ide/stores/actions';
import store from '~/ide/stores'; import store from '~/ide/stores';
import * as types from '~/ide/stores/mutation_types'; import * as types from '~/ide/stores/mutation_types';
...@@ -457,4 +458,19 @@ describe('Multi-file store actions', () => { ...@@ -457,4 +458,19 @@ describe('Multi-file store actions', () => {
); );
}); });
}); });
describe('deleteEntry', () => {
it('commits entry deletion', done => {
store.state.entries.path = 'testing';
testAction(
deleteEntry,
'path',
store.state,
[{ type: types.DELETE_ENTRY, payload: 'path' }],
[{ type: 'burstUnusedSeal' }, { type: 'closeFile', payload: store.state.entries.path }],
done,
);
});
});
}); });
...@@ -123,6 +123,22 @@ describe('IDE commit module getters', () => { ...@@ -123,6 +123,22 @@ describe('IDE commit module getters', () => {
'Update test-file, index.js files', 'Update test-file, index.js files',
); );
}); });
it('returns commitMessage with deleted files', () => {
rootState[key].push(
{
path: 'test-file',
deleted: true,
},
{
path: 'index.js',
},
);
expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe(
'Update index.js\nDeleted test-file',
);
});
}); });
}); });
}); });
...@@ -94,6 +94,35 @@ describe('IDE store file mutations', () => { ...@@ -94,6 +94,35 @@ describe('IDE store file mutations', () => {
expect(localFile.raw).toBe('testing'); expect(localFile.raw).toBe('testing');
}); });
it('adds raw data to open pending file', () => {
localState.openFiles.push({
...localFile,
pending: true,
});
mutations.SET_FILE_RAW_DATA(localState, {
file: localFile,
raw: 'testing',
});
expect(localState.openFiles[0].raw).toBe('testing');
});
it('does not add raw data to open pending tempFile file', () => {
localState.openFiles.push({
...localFile,
pending: true,
tempFile: true,
});
mutations.SET_FILE_RAW_DATA(localState, {
file: localFile,
raw: 'testing',
});
expect(localState.openFiles[0].raw).not.toBe('testing');
});
}); });
describe('SET_FILE_BASE_RAW_DATA', () => { describe('SET_FILE_BASE_RAW_DATA', () => {
...@@ -205,6 +234,11 @@ describe('IDE store file mutations', () => { ...@@ -205,6 +234,11 @@ describe('IDE store file mutations', () => {
beforeEach(() => { beforeEach(() => {
localFile.content = 'test'; localFile.content = 'test';
localFile.changed = true; localFile.changed = true;
localState.currentProjectId = 'gitlab-ce';
localState.currentBranchId = 'master';
localState.trees['gitlab-ce/master'] = {
tree: [],
};
}); });
it('resets content and changed', () => { it('resets content and changed', () => {
...@@ -213,6 +247,36 @@ describe('IDE store file mutations', () => { ...@@ -213,6 +247,36 @@ describe('IDE store file mutations', () => {
expect(localFile.content).toBe(''); expect(localFile.content).toBe('');
expect(localFile.changed).toBeFalsy(); expect(localFile.changed).toBeFalsy();
}); });
it('adds to root tree if deleted', () => {
localFile.deleted = true;
mutations.DISCARD_FILE_CHANGES(localState, localFile.path);
expect(localState.trees['gitlab-ce/master'].tree).toEqual([
{
...localFile,
deleted: false,
},
]);
});
it('adds to parent tree if deleted', () => {
localFile.deleted = true;
localFile.parentPath = 'parentPath';
localState.entries.parentPath = {
tree: [],
};
mutations.DISCARD_FILE_CHANGES(localState, localFile.path);
expect(localState.entries.parentPath.tree).toEqual([
{
...localFile,
deleted: false,
},
]);
});
}); });
describe('ADD_FILE_TO_CHANGED', () => { describe('ADD_FILE_TO_CHANGED', () => {
......
...@@ -156,4 +156,61 @@ describe('Multi-file store mutations', () => { ...@@ -156,4 +156,61 @@ describe('Multi-file store mutations', () => {
expect(localState.errorMessage).toBe('error'); expect(localState.errorMessage).toBe('error');
}); });
}); });
describe('DELETE_ENTRY', () => {
beforeEach(() => {
localState.currentProjectId = 'gitlab-ce';
localState.currentBranchId = 'master';
localState.trees['gitlab-ce/master'] = {
tree: [],
};
});
it('sets deleted flag', () => {
localState.entries.filePath = {
deleted: false,
};
mutations.DELETE_ENTRY(localState, 'filePath');
expect(localState.entries.filePath.deleted).toBe(true);
});
it('removes from root tree', () => {
localState.entries.filePath = {
path: 'filePath',
deleted: false,
};
localState.trees['gitlab-ce/master'].tree.push(localState.entries.filePath);
mutations.DELETE_ENTRY(localState, 'filePath');
expect(localState.trees['gitlab-ce/master'].tree).toEqual([]);
});
it('removes from parent tree', () => {
localState.entries.filePath = {
path: 'filePath',
deleted: false,
parentPath: 'parentPath',
};
localState.entries.parentPath = {
tree: [localState.entries.filePath],
};
mutations.DELETE_ENTRY(localState, 'filePath');
expect(localState.entries.parentPath.tree).toEqual([]);
});
it('adds to changedFiles', () => {
localState.entries.filePath = {
deleted: false,
};
mutations.DELETE_ENTRY(localState, 'filePath');
expect(localState.changedFiles).toEqual([localState.entries.filePath]);
});
});
}); });
...@@ -86,6 +86,11 @@ describe('Multi-file store utils', () => { ...@@ -86,6 +86,11 @@ describe('Multi-file store utils', () => {
base64: true, base64: true,
lastCommitSha: '123456789', lastCommitSha: '123456789',
}, },
{
...file('deletedFile'),
path: 'deletedFile',
deleted: true,
},
], ],
currentBranchId: 'master', currentBranchId: 'master',
}; };
...@@ -115,6 +120,13 @@ describe('Multi-file store utils', () => { ...@@ -115,6 +120,13 @@ describe('Multi-file store utils', () => {
encoding: 'base64', encoding: 'base64',
last_commit_id: '123456789', last_commit_id: '123456789',
}, },
{
action: 'delete',
file_path: 'deletedFile',
content: '',
encoding: 'text',
last_commit_id: undefined,
},
], ],
start_branch: undefined, start_branch: undefined,
}); });
...@@ -173,4 +185,65 @@ describe('Multi-file store utils', () => { ...@@ -173,4 +185,65 @@ describe('Multi-file store utils', () => {
}); });
}); });
}); });
describe('commitActionForFile', () => {
it('returns deleted for deleted file', () => {
expect(utils.commitActionForFile({ deleted: true })).toBe('delete');
});
it('returns create for tempFile', () => {
expect(utils.commitActionForFile({ tempFile: true })).toBe('create');
});
it('returns update by default', () => {
expect(utils.commitActionForFile({})).toBe('update');
});
});
describe('getCommitFiles', () => {
it('returns flattened list of files and folders', () => {
const files = [
{
path: 'a',
type: 'blob',
deleted: true,
},
{
path: 'b',
type: 'tree',
deleted: true,
tree: [
{
path: 'c',
type: 'blob',
},
{
path: 'd',
type: 'blob',
},
],
},
];
const flattendFiles = utils.getCommitFiles(files);
expect(flattendFiles).toEqual([
{
path: 'a',
type: 'blob',
deleted: true,
},
{
path: 'c',
type: 'blob',
deleted: true,
},
{
path: 'd',
type: 'blob',
deleted: true,
},
]);
});
});
}); });
...@@ -29,7 +29,7 @@ describe('Panel Resizer component', () => { ...@@ -29,7 +29,7 @@ describe('Panel Resizer component', () => {
}); });
expect(vm.$el.tagName).toEqual('DIV'); expect(vm.$el.tagName).toEqual('DIV');
expect(vm.$el.getAttribute('class')).toBe('dragHandle dragleft'); expect(vm.$el.getAttribute('class')).toBe('drag-handle drag-left');
expect(vm.$el.getAttribute('style')).toBe('cursor: ew-resize;'); expect(vm.$el.getAttribute('style')).toBe('cursor: ew-resize;');
}); });
...@@ -40,7 +40,7 @@ describe('Panel Resizer component', () => { ...@@ -40,7 +40,7 @@ describe('Panel Resizer component', () => {
}); });
expect(vm.$el.tagName).toEqual('DIV'); expect(vm.$el.tagName).toEqual('DIV');
expect(vm.$el.getAttribute('class')).toBe('dragHandle dragright'); expect(vm.$el.getAttribute('class')).toBe('drag-handle drag-right');
}); });
it('drag the resizer', () => { it('drag the resizer', () => {
......
require 'spec_helper'
describe Gitlab::Graphql::Authorize::AuthorizeResource do
let(:fake_class) do
Class.new do
include Gitlab::Graphql::Authorize::AuthorizeResource
attr_reader :user, :found_object
authorize :read_the_thing
def initialize(user, found_object)
@user, @found_object = user, found_object
end
def find_object
found_object
end
def current_user
user
end
end
end
let(:user) { build(:user) }
let(:project) { build(:project) }
subject(:loading_resource) { fake_class.new(user, project) }
context 'when the user is allowed to perform the action' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_the_thing, project, scope: :user) do
true
end
end
describe '#authorized_find' do
it 'returns the object' do
expect(loading_resource.authorized_find).to eq(project)
end
end
describe '#authorized_find!' do
it 'returns the object' do
expect(loading_resource.authorized_find!).to eq(project)
end
end
describe '#authorize!' do
it 'does not raise an error' do
expect { loading_resource.authorize!(project) }.not_to raise_error
end
end
describe '#authorized?' do
it 'is true' do
expect(loading_resource.authorized?(project)).to be(true)
end
end
end
context 'when the user is not allowed to perform the action' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_the_thing, project, scope: :user) do
false
end
end
describe '#authorized_find' do
it 'returns `nil`' do
expect(loading_resource.authorized_find).to be_nil
end
end
describe '#authorized_find!' do
it 'raises an error' do
expect { loading_resource.authorize!(project) }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
describe '#authorize!' do
it 'does not raise an error' do
expect { loading_resource.authorize!(project) }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
describe '#authorized?' do
it 'is false' do
expect(loading_resource.authorized?(project)).to be(false)
end
end
end
context 'when the class does not define #find_object' do
let(:fake_class) do
Class.new { include Gitlab::Graphql::Authorize::AuthorizeResource }
end
it 'raises a comprehensive error message' do
expect { fake_class.new.find_object }.to raise_error(/Implement #find_object in #{fake_class.name}/)
end
end
end
require 'spec_helper'
describe Gitlab::Graphql::Authorize do
describe '#authorize' do
it 'adds permissions from subclasses to those of superclasses when used on classes' do
base_class = Class.new do
extend Gitlab::Graphql::Authorize
authorize :base_authorization
end
sub_class = Class.new(base_class) do
authorize :sub_authorization
end
expect(base_class.required_permissions).to contain_exactly(:base_authorization)
expect(sub_class.required_permissions)
.to contain_exactly(:base_authorization, :sub_authorization)
end
end
end
require 'spec_helper'
describe 'Setting WIP status of a merge request' do
include GraphqlHelpers
let(:current_user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
let(:input) { { wip: true } }
let(:mutation) do
variables = {
project_path: project.full_path,
iid: merge_request.iid
}
graphql_mutation(:merge_request_set_wip, variables.merge(input))
end
def mutation_response
graphql_mutation_response(:merge_request_set_wip)
end
before do
project.add_developer(current_user)
end
it 'returns an error if the user is not allowed to update the merge request' do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
it 'marks the merge request as WIP' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['mergeRequest']['title']).to start_with('WIP:')
end
it 'does not do anything if the merge request was already marked `WIP`' do
merge_request.update!(title: 'wip: hello world')
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['mergeRequest']['title']).to start_with('wip:')
end
context 'when passing WIP false as input' do
let(:input) { { wip: false } }
it 'does not do anything if the merge reqeust was not marked wip' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['mergeRequest']['title']).not_to start_with(/wip\:/)
end
it 'unmarks the merge request as `WIP`' do
merge_request.update!(title: 'wip: hello world')
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['mergeRequest']['title']).not_to start_with('/wip\:/')
end
end
end
module GraphqlHelpers module GraphqlHelpers
MutationDefinition = Struct.new(:query, :variables)
# makes an underscored string look like a fieldname # makes an underscored string look like a fieldname
# "merge_request" => "mergeRequest" # "merge_request" => "mergeRequest"
def self.fieldnamerize(underscored_field_name) def self.fieldnamerize(underscored_field_name)
...@@ -41,6 +43,37 @@ module GraphqlHelpers ...@@ -41,6 +43,37 @@ module GraphqlHelpers
QUERY QUERY
end end
def graphql_mutation(name, input, fields = nil)
mutation_name = GraphqlHelpers.fieldnamerize(name)
input_variable_name = "$#{input_variable_name_for_mutation(name)}"
mutation_field = GitlabSchema.mutation.fields[mutation_name]
fields ||= all_graphql_fields_for(mutation_field.type)
query = <<~MUTATION
mutation(#{input_variable_name}: #{mutation_field.arguments['input'].type}) {
#{mutation_name}(input: #{input_variable_name}) {
#{fields}
}
}
MUTATION
variables = variables_for_mutation(name, input)
MutationDefinition.new(query, variables)
end
def variables_for_mutation(name, input)
graphql_input = input.map { |name, value| [GraphqlHelpers.fieldnamerize(name), value] }.to_h
{ input_variable_name_for_mutation(name) => graphql_input }.to_json
end
def input_variable_name_for_mutation(mutation_name)
mutation_name = GraphqlHelpers.fieldnamerize(mutation_name)
mutation_field = GitlabSchema.mutation.fields[mutation_name]
input_type = field_type(mutation_field.arguments['input'])
GraphqlHelpers.fieldnamerize(input_type)
end
def query_graphql_field(name, attributes = {}, fields = nil) def query_graphql_field(name, attributes = {}, fields = nil)
fields ||= all_graphql_fields_for(name.classify) fields ||= all_graphql_fields_for(name.classify)
attributes = attributes_to_graphql(attributes) attributes = attributes_to_graphql(attributes)
...@@ -73,8 +106,12 @@ module GraphqlHelpers ...@@ -73,8 +106,12 @@ module GraphqlHelpers
end.join(", ") end.join(", ")
end end
def post_graphql(query, current_user: nil) def post_graphql(query, current_user: nil, variables: nil)
post api('/', current_user, version: 'graphql'), query: query post api('/', current_user, version: 'graphql'), query: query, variables: variables
end
def post_graphql_mutation(mutation, current_user: nil)
post_graphql(mutation.query, current_user: current_user, variables: mutation.variables)
end end
def graphql_data def graphql_data
...@@ -82,7 +119,11 @@ module GraphqlHelpers ...@@ -82,7 +119,11 @@ module GraphqlHelpers
end end
def graphql_errors def graphql_errors
json_response['data'] json_response['errors']
end
def graphql_mutation_response(mutation_name)
graphql_data[GraphqlHelpers.fieldnamerize(mutation_name)]
end end
def nested_fields?(field) def nested_fields?(field)
...@@ -102,10 +143,14 @@ module GraphqlHelpers ...@@ -102,10 +143,14 @@ module GraphqlHelpers
end end
def field_type(field) def field_type(field)
if field.type.respond_to?(:of_type) field_type = field.type
field.type.of_type
else # The type could be nested. For example `[GraphQL::STRING_TYPE]`:
field.type # - List
end # - String!
# - String
field_type = field_type.of_type while field_type.respond_to?(:of_type)
field_type
end end
end end
...@@ -34,6 +34,15 @@ RSpec::Matchers.define :have_graphql_field do |field_name| ...@@ -34,6 +34,15 @@ RSpec::Matchers.define :have_graphql_field do |field_name|
end end
end end
RSpec::Matchers.define :have_graphql_mutation do |mutation_class|
match do |mutation_type|
field = mutation_type.fields[GraphqlHelpers.fieldnamerize(mutation_class.graphql_name)]
expect(field).to be_present
expect(field.resolver).to eq(mutation_class)
end
end
RSpec::Matchers.define :have_graphql_arguments do |*expected| RSpec::Matchers.define :have_graphql_arguments do |*expected|
include GraphqlHelpers include GraphqlHelpers
......
...@@ -5,7 +5,7 @@ shared_examples 'a working graphql query' do ...@@ -5,7 +5,7 @@ shared_examples 'a working graphql query' do
it 'returns a successful response', :aggregate_failures do it 'returns a successful response', :aggregate_failures do
expect(response).to have_gitlab_http_status(:success) expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors['errors']).to be_nil expect(graphql_errors).to be_nil
expect(json_response.keys).to include('data') expect(json_response.keys).to include('data')
end end
end end
...@@ -29,6 +29,39 @@ describe 'layouts/_head' do ...@@ -29,6 +29,39 @@ describe 'layouts/_head' do
expect(rendered).to match(%{content="foo&quot; http-equiv=&quot;refresh"}) expect(rendered).to match(%{content="foo&quot; http-equiv=&quot;refresh"})
end end
context 'when an asset_host is set and feature is activated in the config it will' do
let(:asset_host) { 'http://assets' }
before do
stub_feature_flags(asset_host_prefetch: true)
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
end
it 'add a link dns-prefetch tag' do
render
expect(rendered).to match('<link href="http://assets" rel="dns-prefetch">')
end
it 'add a link preconnect tag' do
render
expect(rendered).to match('<link crossorigin="" href="http://assets" rel="preconnnect">')
end
end
context 'when an asset_host is set and feature is not activated in the config it will' do
let(:asset_host) { 'http://assets' }
before do
stub_feature_flags(asset_host_prefetch: false)
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
end
it 'not add a link dns-prefetch tag' do
render
expect(rendered).not_to match('<link href="http://assets" rel="dns-prefetch">')
end
end
def stub_helper_with_safe_string(method) def stub_helper_with_safe_string(method)
allow_any_instance_of(PageLayoutHelper).to receive(method) allow_any_instance_of(PageLayoutHelper).to receive(method)
.and_return(%q{foo" http-equiv="refresh}.html_safe) .and_return(%q{foo" http-equiv="refresh}.html_safe)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment