Commit c96913e8 authored by GitLab Bot's avatar GitLab Bot

Merge remote-tracking branch 'upstream/master' into ce-to-ee-2018-07-26

# Conflicts:
#	Gemfile.lock
#	app/assets/javascripts/boards/index.js
#	app/assets/javascripts/vue_shared/components/reports/issues_list.vue
#	app/assets/javascripts/vue_shared/components/reports/report_issues.vue
#	app/helpers/environments_helper.rb
#	lib/gitlab/prometheus/queries/query_additional_metrics.rb
#	locale/gitlab.pot

[ci skip]
parents 4f991989 8e675082
......@@ -572,13 +572,12 @@ setup-test-env:
- vendor/gitaly-ruby
danger-review:
image: registry.gitlab.com/gitlab-org/gitaly/dangercontainer:latest
image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger
stage: test
allow_failure: true
before_script:
- source scripts/utils.sh
- retry gem install danger --no-ri --no-rdoc
cache: {}
dependencies: []
before_script: []
only:
variables:
- $DANGER_GITLAB_API_TOKEN
......
......@@ -400,9 +400,14 @@ GEM
google-protobuf (~> 3.1)
googleapis-common-protos-types (~> 1.0.0)
googleauth (>= 0.5.1, < 0.7)
<<<<<<< HEAD
gssapi (1.2.0)
ffi (>= 1.0.1)
haml (4.0.7)
=======
haml (5.0.4)
temple (>= 0.8.0)
>>>>>>> upstream/master
tilt
haml_lint (0.26.0)
haml (>= 4.0, < 5.1)
......
......@@ -178,6 +178,7 @@ export default () => {
});
}
},
<<<<<<< HEAD
updateWeight(newWeight, id) {
const { issue } = Store.detail;
if (issue.id === id && issue.sidebarInfoEndpoint) {
......@@ -196,6 +197,8 @@ export default () => {
});
}
},
=======
>>>>>>> upstream/master
},
});
......@@ -210,6 +213,7 @@ export default () => {
},
});
<<<<<<< HEAD
const configEl = document.querySelector('.js-board-config');
if (configEl) {
......@@ -283,6 +287,36 @@ export default () => {
return '';
},
},
=======
const issueBoardsModal = document.getElementById('js-add-issues-btn');
if (issueBoardsModal) {
gl.IssueBoardsModalAddBtn = new Vue({
el: issueBoardsModal,
mixins: [modalMixin],
data() {
return {
modal: ModalStore.store,
store: Store.state,
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
};
},
computed: {
disabled() {
if (!this.store) {
return true;
}
return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
if (this.disabled) {
return 'Please add a list to your board first';
}
return '';
},
},
>>>>>>> upstream/master
watch: {
disabled() {
this.updateTooltip();
......@@ -308,6 +342,7 @@ export default () => {
this.toggleModal(true);
}
},
<<<<<<< HEAD
},
template: `
<div class="board-extra-actions">
......@@ -376,4 +411,25 @@ export default () => {
'boards-selector': gl.issueBoards.BoardsSelector,
},
});
=======
},
template: `
<div class="board-extra-actions">
<button
class="btn btn-create prepend-left-10"
type="button"
data-placement="bottom"
ref="addIssuesButton"
:class="{ 'disabled': disabled }"
:title="tooltipTitle"
:aria-disabled="disabled"
v-if="canAdminList"
@click="openModal">
Add issues
</button>
</div>
`,
});
}
>>>>>>> upstream/master
};
......@@ -3,6 +3,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
import { getCommitIconMap } from '../utils';
export default {
components: {
......@@ -34,16 +35,14 @@ export default {
},
computed: {
changedIcon() {
const suffix = this.file.staged && !this.showStagedIcon ? '-solid' : '';
return this.file.tempFile && !this.forceModifiedIcon
? `file-addition${suffix}`
: `file-modified${suffix}`;
},
stagedIcon() {
return `${this.changedIcon}-solid`;
const suffix = !this.file.changed && this.file.staged && !this.showStagedIcon ? '-solid' : '';
if (this.forceModifiedIcon) return `file-modified${suffix}`;
return `${getCommitIconMap(this.file).icon}${suffix}`;
},
changedIconClass() {
return `multi-${this.changedIcon} float-left`;
return `ide-${this.changedIcon} float-left`;
},
tooltipTitle() {
if (!this.showTooltip) return undefined;
......@@ -66,6 +65,9 @@ export default {
return undefined;
},
showIcon() {
return this.file.changed || this.file.tempFile || this.file.staged || this.file.deleted;
},
},
};
</script>
......@@ -79,7 +81,7 @@ export default {
class="ide-file-changed-icon"
>
<icon
v-if="file.changed || file.tempFile || file.staged"
v-if="showIcon"
:name="changedIcon"
:size="12"
:css-classes="changedIconClass"
......
......@@ -5,6 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import StageButton from './stage_button.vue';
import UnstageButton from './unstage_button.vue';
import { viewerTypes } from '../../constants';
import { getCommitIconMap } from '../../utils';
export default {
components: {
......@@ -42,11 +43,12 @@ export default {
},
computed: {
iconName() {
const prefix = this.stagedList ? '-solid' : '';
return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`;
const suffix = this.stagedList ? '-solid' : '';
return `${getCommitIconMap(this.file).icon}${suffix}`;
},
iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
return `${getCommitIconMap(this.file).class} append-right-8`;
},
fullKey() {
return `${this.keyPrefix}-${this.file.key}`;
......@@ -67,6 +69,8 @@ export default {
'stageChange',
]),
openFileInEditor() {
if (this.file.type === 'tree') return null;
return this.openPendingTab({
file: this.file,
keyPrefix: this.keyPrefix,
......
......@@ -10,7 +10,7 @@ export default {
EditorModeDropdown,
},
computed: {
...mapGetters(['currentMergeRequest']),
...mapGetters(['currentMergeRequest', 'activeFile']),
...mapState(['viewer', 'currentMergeRequestId']),
showLatestChangesText() {
return !this.currentMergeRequestId || this.viewer === viewerTypes.diff;
......@@ -23,12 +23,20 @@ export default {
},
},
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.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
});
},
methods: {
...mapActions(['updateViewer']),
...mapActions(['updateViewer', 'resetOpenFiles']),
},
};
</script>
......@@ -36,7 +44,6 @@ export default {
<template>
<ide-tree-list
:viewer-type="viewer"
:disable-action-dropdown="true"
header-class="ide-review-header"
>
<template
......
......@@ -17,14 +17,18 @@ export default {
...mapGetters(['currentProject', 'currentTree', 'activeFile']),
},
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.updateViewer('editor');
});
} else if (this.activeFile.deleted) {
this.resetOpenFiles();
}
},
methods: {
...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry']),
...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry', 'resetOpenFiles']),
},
};
</script>
......
......@@ -22,11 +22,6 @@ export default {
required: false,
default: null,
},
disableActionDropdown: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['currentBranchId']),
......@@ -69,7 +64,6 @@ export default {
:key="file.key"
:file="file"
:level="0"
:disable-action-dropdown="disableActionDropdown"
/>
</template>
</div>
......
......@@ -13,7 +13,7 @@ export default {
ItemButton,
},
props: {
branch: {
type: {
type: String,
required: true,
},
......@@ -45,7 +45,7 @@ export default {
},
},
methods: {
...mapActions(['createTempEntry', 'openNewEntryModal']),
...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']),
createNewItem(type) {
this.openNewEntryModal({ type, path: this.path });
this.dropdownOpen = false;
......@@ -82,28 +82,40 @@ export default {
ref="dropdownMenu"
class="dropdown-menu dropdown-menu-right"
>
<template v-if="type === 'tree'">
<li>
<item-button
:label="__('New file')"
class="d-flex"
icon="doc-new"
icon-classes="mr-2"
@click="createNewItem('blob')"
/>
</li>
<li>
<upload
:path="path"
@create="createTempEntry"
/>
</li>
<li>
<item-button
:label="__('New directory')"
class="d-flex"
icon="folder-new"
icon-classes="mr-2"
@click="createNewItem('tree')"
/>
</li>
<li class="divider"></li>
</template>
<li>
<item-button
:label="__('New file')"
:label="__('Delete')"
class="d-flex"
icon="doc-new"
icon="remove"
icon-classes="mr-2"
@click="createNewItem('blob')"
/>
</li>
<li>
<upload
:path="path"
@create="createTempEntry"
/>
</li>
<li>
<item-button
:label="__('New directory')"
class="d-flex"
icon="folder-new"
icon-classes="mr-2"
@click="createNewItem('tree')"
@click="deleteEntry(path)"
/>
</li>
</ul>
......
......@@ -44,7 +44,7 @@ export default {
},
},
mounted() {
if (this.lastOpenedFile) {
if (this.lastOpenedFile && this.lastOpenedFile.type !== 'tree') {
this.openPendingTab({
file: this.lastOpenedFile,
keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged,
......
......@@ -87,7 +87,9 @@ export default {
this.editor.updateDimensions();
},
viewer() {
this.createEditorInstance();
if (!this.file.pending) {
this.createEditorInstance();
}
},
panelResizing() {
if (!this.panelResizing) {
......@@ -109,6 +111,7 @@ export default {
},
methods: {
...mapActions([
'getFileData',
'getRawFileData',
'changeFileContent',
'setFileLanguage',
......@@ -123,10 +126,16 @@ export default {
this.editor.clearEditor();
this.getRawFileData({
this.getFileData({
path: this.file.path,
baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '',
makeFileActive: false,
})
.then(() =>
this.getRawFileData({
path: this.file.path,
baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '',
}),
)
.then(() => {
this.createEditorInstance();
})
......@@ -246,6 +255,8 @@ export default {
ref="editor"
:class="{
'is-readonly': isCommitModeActive,
'is-deleted': file.deleted,
'is-added': file.tempFile
}"
class="multi-file-editor-holder"
>
......
......@@ -34,11 +34,6 @@ export default {
type: Number,
required: true,
},
disableActionDropdown: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -212,8 +207,7 @@ export default {
/>
</span>
<new-dropdown
v-if="isTree && !disableActionDropdown"
:project-id="file.projectId"
:type="file.type"
:branch="file.branchId"
:path="file.path"
:mouse-over="mouseOver"
......
......@@ -37,7 +37,7 @@ export default {
return this.fileHasChanged ? !this.tabMouseOver : false;
},
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 {
<template>
<li
:class="{
active: tab.active
active: tab.active,
disabled: tab.pending
}"
@click="clickFile(tab)"
@mouseover="mouseOverTab"
......@@ -105,7 +106,6 @@ export default {
<changed-file-icon
v-else
:file="tab"
:force-modified-icon="true"
/>
</button>
</li>
......
......@@ -38,3 +38,18 @@ export const stageKeys = {
unstaged: 'unstaged',
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 {
this.disposable = new Disposable();
this.file = file;
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.originalModel = monacoEditor.createModel(
......
......@@ -18,7 +18,7 @@ export default {
return axios
.get(file.rawPath, {
params: { format: 'json' },
transformResponse: [f => f],
})
.then(({ data }) => data);
},
......@@ -33,7 +33,7 @@ export default {
return axios
.get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), {
params: { format: 'json' },
transformResponse: [f => f],
})
.then(({ data }) => data);
},
......
......@@ -185,6 +185,14 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
$('#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/file';
export * from './actions/project';
......
......@@ -61,7 +61,11 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => {
const file = state.entries[path];
if (file.raw || file.tempFile) return Promise.resolve();
commit(types.TOGGLE_LOADING, { entry: file });
return service
.getFileData(
`${gon.relative_url_root ? gon.relative_url_root : ''}${file.url.replace('/-/', '/')}`,
......@@ -71,7 +75,7 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive
setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE']));
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);
commit(types.TOGGLE_LOADING, { entry: file });
})
......@@ -97,7 +101,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) =
service
.getRawFileData(file)
.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) {
service
.getBaseRawFileData(file, baseSha)
......
......@@ -21,14 +21,12 @@ export const showTreeEntry = ({ commit, dispatch, state }, path) => {
export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
if (row.type === 'tree') {
dispatch('toggleTreeOpen', row.path);
} else if (row.type === 'blob' && (row.opened || row.changed)) {
if (row.changed && !row.opened) {
} else if (row.type === 'blob') {
if (!row.opened) {
commit(types.TOGGLE_FILE_OPEN, row.path);
}
dispatch('setFileActive', row.path);
} else {
dispatch('getFileData', { path: row.path });
}
dispatch('showTreeEntry', row.path);
......
......@@ -174,11 +174,13 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch('updateActivityBarView', activityBarViews.edit, { root: true });
dispatch('updateViewer', 'editor', { root: true });
router.push(
`/project/${rootState.currentProjectId}/blob/${getters.branchName}/-/${
rootGetters.activeFile.path
}`,
);
if (rootGetters.activeFile) {
router.push(
`/project/${rootState.currentProjectId}/blob/${getters.branchName}/-/${
rootGetters.activeFile.path
}`,
);
}
}
})
.then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH))
......
import { sprintf, n__ } from '../../../../locale';
import { sprintf, n__, __ } from '../../../../locale';
import * as consts from './constants';
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 =>
state.commitMessage === '' || state.submitCommitLoading;
......@@ -29,14 +37,16 @@ export const branchName = (state, getters, rootState) => {
export const preBuiltCommitMessage = (state, _, rootState) => {
if (state.commitMessage) return state.commitMessage;
const files = (rootState.stagedFiles.length
? rootState.stagedFiles
: rootState.changedFiles
).reduce((acc, val) => acc.concat(val.path), []);
const files = rootState.stagedFiles.length ? rootState.stagedFiles : rootState.changedFiles;
const modifiedFiles = files.filter(f => !f.deleted);
const deletedFiles = files.filter(f => f.deleted);
return sprintf(n__('Update %{files}', 'Update %{files} files', files.length), {
files: files.join(', '),
});
return [
createTranslatedTextForFiles(modifiedFiles, __('Update')),
createTranslatedTextForFiles(deletedFiles, __('Deleted')),
]
.filter(t => t)
.join('\n');
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
......
......@@ -76,3 +76,4 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
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 projectMutations from './mutations/project';
import mergeRequestMutation from './mutations/merge_request';
......@@ -171,6 +172,16 @@ export default {
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,
...mergeRequestMutation,
...fileMutations,
......
/* eslint-disable no-param-reassign */
import * as types from '../mutation_types';
import { sortTree } from '../utils';
import { diffModes } from '../../constants';
export default {
......@@ -51,9 +52,17 @@ export default {
});
},
[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], {
raw,
});
if (openPendingFile) {
openPendingFile.raw = raw;
}
},
[types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) {
Object.assign(state.entries[file.path], {
......@@ -109,11 +118,22 @@ export default {
},
[types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
const entry = state.entries[path];
const { deleted } = entry;
Object.assign(state.entries[path], {
content: stagedFile ? stagedFile.content : state.entries[path].raw,
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) {
Object.assign(state, {
......
......@@ -46,6 +46,7 @@ export const dataStructure = () => ({
parentPath: null,
lastOpenedAt: 0,
mrChange: null,
deleted: false,
});
export const decorateData = entity => {
......@@ -105,15 +106,37 @@ export const setPageTitle = 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 }) => ({
branch,
commit_message: state.commitMessage || getters.preBuiltCommitMessage,
actions: rootState.stagedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
actions: getCommitFiles(rootState.stagedFiles).map(f => ({
action: commitActionForFile(f),
file_path: f.path,
content: f.content,
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,
});
......
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 @@
},
computed: {
className() {
return `drag${this.side}`;
return `drag-${this.side}`;
},
cursorStyle() {
if (this.enabled) {
......@@ -44,8 +44,15 @@
methods: {
resetSize(e) {
e.preventDefault();
this.$emit('resize-start', this.size);
this.size = this.startSize;
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) {
if (this.enabled) {
......@@ -84,7 +91,7 @@
<div
:class="className"
:style="cursorStyle"
class="dragHandle"
class="drag-handle"
@mousedown="startDrag"
@dblclick="resetSize"
></div>
......
......@@ -5,9 +5,12 @@ import {
STATUS_FAILED,
STATUS_NEUTRAL,
} from '~/vue_shared/components/reports/constants';
<<<<<<< HEAD
import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import SastContainerInfo from 'ee/vue_shared/security_reports/components/sast_container_info.vue';
=======
>>>>>>> upstream/master
/**
* Renders block of issues
......@@ -18,7 +21,10 @@ export default {
IssuesBlock,
SastContainerInfo,
},
<<<<<<< HEAD
componentNames,
=======
>>>>>>> upstream/master
success: STATUS_SUCCESS,
failed: STATUS_FAILED,
neutral: STATUS_NEUTRAL,
......
<script>
import IssueStatusIcon from '~/vue_shared/components/reports/issue_status_icon.vue';
<<<<<<< HEAD
import { components, componentNames } from 'ee/vue_shared/components/reports/issue_body';
=======
import { components, componentNames } from '~/vue_shared/components/reports/issue_body';
>>>>>>> upstream/master
export default {
name: 'ReportIssues',
......
......@@ -567,9 +567,6 @@
border-bottom: 1px solid $white-normal;
.mx-auto {
margin: 8px 0;
text-align: center;
.tanuki-logo,
img {
height: 36px;
......
......@@ -874,3 +874,5 @@ $font-family-monospace: $monospace-font;
$input-line-height: 20px;
$btn-line-height: 20px;
$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-target-form {
display: inline-block;
......@@ -74,6 +77,7 @@
.ide-file-icon-holder {
display: flex;
align-items: center;
color: $theme-gray-700;
}
.ide-file-changed-icon {
......@@ -161,12 +165,23 @@
background-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 {
@include str-truncated(141px);
cursor: pointer;
svg {
vertical-align: middle;
......@@ -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 {
.editor.modified {
box-shadow: none;
......@@ -557,16 +604,21 @@
}
}
.multi-file-addition,
.multi-file-addition-solid {
.ide-file-addition,
.ide-file-addition-solid {
color: $green-500;
}
.multi-file-modified,
.multi-file-modified-solid {
.ide-file-modified,
.ide-file-modified-solid {
color: $orange-500;
}
.ide-file-deletion,
.ide-file-deletion-solid {
color: $red-500;
}
.multi-file-commit-list-collapsed {
display: flex;
flex-direction: column;
......@@ -781,18 +833,21 @@
}
}
.dragHandle {
.drag-handle {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: $white-dark;
width: 4px;
&:hover {
background-color: $white-normal;
}
&.dragright {
&.drag-right {
right: 0;
}
&.dragleft {
&.drag-left {
left: 0;
}
}
......@@ -1014,6 +1069,10 @@
.ide-new-btn {
margin-left: auto;
}
button {
color: $gl-text-color;
}
}
.ide-sidebar-branch-title {
......
......@@ -199,6 +199,7 @@
.block {
width: 100%;
word-break: break-word;
&:last-child {
border-bottom: 1px solid $border-gray-normal;
......
......@@ -4,13 +4,17 @@ class Projects::CommitsController < Projects::ApplicationController
include ExtractsPath
include RendersCommits
before_action :whitelist_query_limiting
before_action :whitelist_query_limiting, except: :commits_root
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 :set_commits
before_action :set_commits, except: :commits_root
before_action :set_request_format, only: :show
def commits_root
redirect_to project_commits_path(@project, @project.default_branch)
end
def show
@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)
......
......@@ -7,5 +7,5 @@ class GitlabSchema < GraphQL::Schema
query(Types::QueryType)
default_max_page_size 100
# mutation(Types::MutationType)
mutation(Types::MutationType)
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
class MutationType < BaseObject
include Gitlab::Graphql::MountMutation
graphql_name "Mutation"
# TODO: Add Mutations as fields
mount_mutation Mutations::MergeRequests::SetWip
end
end
......@@ -11,6 +11,10 @@ module EnvironmentsHelper
{
"settings-path" => edit_project_service_path(project, 'prometheus'),
"clusters-path" => project_clusters_path(project),
<<<<<<< HEAD
=======
"current-environment-name": environment.name,
>>>>>>> upstream/master
"documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'),
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'),
......
- @body_class = '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'),
"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'),
......
......@@ -3,6 +3,11 @@
- site_name = "GitLab"
%head{ prefix: "og: http://ogp.me/ns#" }
%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' }
-# 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
#{config.root}/app/services/concerns
#{config.root}/app/serializers/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")
......@@ -146,6 +147,7 @@ module Gitlab
config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.css"
config.assets.precompile << "xterm/xterm.css"
config.assets.precompile << "page_bundles/ide.css"
config.assets.precompile << "performance_bar.css"
config.assets.precompile << "lib/ace.js"
config.assets.precompile << "test.css"
......
......@@ -90,6 +90,7 @@ scope format: false do
get '/raw/*id', to: 'raw#show', as: :raw
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', to: 'commits#show', as: :commits
......
......@@ -41,8 +41,8 @@ end
all_files = git.added_files + git.modified_files
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?
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?
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?
......
......@@ -6,22 +6,21 @@ MAGIC_COMMENT = "# frozen_string_literal: true"
def get_files_with_no_magic_comment(files)
files.select do |file|
file.end_with?(FILE_EXTENSION) &&
!File.open(file, &:gets).start_with?(MAGIC_COMMENT)
!File.open(file, &:gets)&.start_with?(MAGIC_COMMENT)
end
end
files_to_check = git.added_files
files_to_fix = get_files_with_no_magic_comment(files_to_check)
files_to_fix = get_files_with_no_magic_comment(git.added_files)
if files_to_fix.any?
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.'
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
end
......@@ -201,6 +201,148 @@ lot of dependant objects.
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
_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.
Using the `GraphqlHelpers#all_graphql_fields_for`-helper, a query
including all available fields can be constructed. This makes it easy
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
end
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
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
module Errors
BaseError = Class.new(GraphQL::ExecutionError)
ArgumentError = Class.new(BaseError)
ResourceNotAvailable = Class.new(BaseError)
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
......@@ -2,8 +2,11 @@ module Gitlab
module Prometheus
module Queries
module QueryAdditionalMetrics
<<<<<<< HEAD
prepend EE::Gitlab::Prometheus::Queries::QueryAdditionalMetrics
=======
>>>>>>> upstream/master
def query_metrics(project, environment, query_context)
matched_metrics(project).map(&query_group(query_context))
.select(&method(:group_with_any_metrics))
......
......@@ -146,6 +146,11 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts
msgstr[0] ""
msgstr[1] ""
msgid "%{text} %{files}"
msgid_plural "%{text} %{files} files"
msgstr[0] ""
msgstr[1] ""
msgid "%{text} is available"
msgstr ""
......@@ -2297,6 +2302,9 @@ msgstr ""
msgid "Delete list"
msgstr ""
msgid "Deleted"
msgstr ""
msgid "Deny"
msgstr ""
......@@ -6705,10 +6713,8 @@ msgstr ""
msgid "Up to date"
msgstr ""
msgid "Update %{files}"
msgid_plural "Update %{files} files"
msgstr[0] ""
msgstr[1] ""
msgid "Update"
msgstr ""
msgid "Update your group name, description, avatar, and other general settings."
msgstr ""
......@@ -7237,6 +7243,7 @@ msgstr ""
msgid "branch name"
msgstr ""
<<<<<<< HEAD
msgid "by"
msgstr ""
......@@ -7447,6 +7454,8 @@ msgstr ""
msgid "ciReport|on pipeline"
msgstr ""
=======
>>>>>>> upstream/master
msgid "command line instructions"
msgstr ""
......
......@@ -72,6 +72,7 @@ module QA
Capybara::Selenium::Driver.new(
app,
browser: :chrome,
clear_local_storage: true,
desired_capabilities: capabilities,
options: options
)
......
......@@ -9,6 +9,18 @@ describe Projects::CommitsController do
project.add_maintainer(user)
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
render_views
......
......@@ -18,8 +18,6 @@ describe GitlabSchema do
end
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)
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', () => {
});
describe('changedIconClass', () => {
it('includes multi-file-modified when not a temp file', () => {
expect(vm.changedIconClass).toContain('multi-file-modified');
it('includes ide-file-modified when not a temp file', () => {
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;
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', () => {
expect(vm.iconName).toBe('file-addition');
});
it('returns deletion', () => {
f.deleted = true;
expect(vm.iconName).toBe('file-deletion');
});
});
describe('iconClass', () => {
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', () => {
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', () => {
branch: 'master',
path: '',
mouseOver: false,
type: 'tree',
});
vm.$store.state.currentProjectId = 'abcproject';
......@@ -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 MockAdapter from 'axios-mock-adapter';
import '~/behaviors/markdown/render_gfm';
import axios from '~/lib/utils/axios_utils';
import store from '~/ide/stores';
import repoEditor from '~/ide/components/repo_editor.vue';
......@@ -25,6 +26,8 @@ describe('RepoEditor', () => {
vm.$store.state.openFiles.push(f);
Vue.set(vm.$store.state.entries, f.path, f);
spyOn(vm, 'getFileData').and.returnValue(Promise.resolve());
vm.$mount();
Vue.nextTick(() => setTimeout(done));
......
......@@ -91,25 +91,6 @@ describe('RepoFile', () => {
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', () => {
......
......@@ -93,13 +93,13 @@ describe('RepoTab', () => {
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.multi-file-modified')).toBeNull();
expect(vm.$el.querySelector('.ide-file-modified')).toBeNull();
vm.$el.dispatchEvent(new Event('mouseout'));
})
.then(Vue.nextTick)
.then(() => {
expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull();
expect(vm.$el.querySelector('.ide-file-modified')).not.toBeNull();
done();
})
......
......@@ -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', () => {
beforeEach(() => {
mock.onGet(/(.*)/).networkError();
......
......@@ -7,6 +7,7 @@ import actions, {
updateActivityBarView,
updateTempFlagForEntry,
setErrorMessage,
deleteEntry,
} from '~/ide/stores/actions';
import store from '~/ide/stores';
import * as types from '~/ide/stores/mutation_types';
......@@ -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', () => {
'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', () => {
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', () => {
......@@ -205,6 +234,11 @@ describe('IDE store file mutations', () => {
beforeEach(() => {
localFile.content = 'test';
localFile.changed = true;
localState.currentProjectId = 'gitlab-ce';
localState.currentBranchId = 'master';
localState.trees['gitlab-ce/master'] = {
tree: [],
};
});
it('resets content and changed', () => {
......@@ -213,6 +247,36 @@ describe('IDE store file mutations', () => {
expect(localFile.content).toBe('');
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', () => {
......
......@@ -156,4 +156,61 @@ describe('Multi-file store mutations', () => {
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', () => {
base64: true,
lastCommitSha: '123456789',
},
{
...file('deletedFile'),
path: 'deletedFile',
deleted: true,
},
],
currentBranchId: 'master',
};
......@@ -115,6 +120,13 @@ describe('Multi-file store utils', () => {
encoding: 'base64',
last_commit_id: '123456789',
},
{
action: 'delete',
file_path: 'deletedFile',
content: '',
encoding: 'text',
last_commit_id: undefined,
},
],
start_branch: undefined,
});
......@@ -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', () => {
});
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;');
});
......@@ -40,7 +40,7 @@ describe('Panel Resizer component', () => {
});
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', () => {
......
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
MutationDefinition = Struct.new(:query, :variables)
# makes an underscored string look like a fieldname
# "merge_request" => "mergeRequest"
def self.fieldnamerize(underscored_field_name)
......@@ -41,6 +43,37 @@ module GraphqlHelpers
QUERY
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)
fields ||= all_graphql_fields_for(name.classify)
attributes = attributes_to_graphql(attributes)
......@@ -73,8 +106,12 @@ module GraphqlHelpers
end.join(", ")
end
def post_graphql(query, current_user: nil)
post api('/', current_user, version: 'graphql'), query: query
def post_graphql(query, current_user: nil, variables: nil)
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
def graphql_data
......@@ -82,7 +119,11 @@ module GraphqlHelpers
end
def graphql_errors
json_response['data']
json_response['errors']
end
def graphql_mutation_response(mutation_name)
graphql_data[GraphqlHelpers.fieldnamerize(mutation_name)]
end
def nested_fields?(field)
......@@ -102,10 +143,14 @@ module GraphqlHelpers
end
def field_type(field)
if field.type.respond_to?(:of_type)
field.type.of_type
else
field.type
end
field_type = field.type
# The type could be nested. For example `[GraphQL::STRING_TYPE]`:
# - List
# - String!
# - String
field_type = field_type.of_type while field_type.respond_to?(:of_type)
field_type
end
end
......@@ -34,6 +34,15 @@ RSpec::Matchers.define :have_graphql_field do |field_name|
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|
include GraphqlHelpers
......
......@@ -5,7 +5,7 @@ shared_examples 'a working graphql query' do
it 'returns a successful response', :aggregate_failures do
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')
end
end
......@@ -29,6 +29,39 @@ describe 'layouts/_head' do
expect(rendered).to match(%{content="foo&quot; http-equiv=&quot;refresh"})
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)
allow_any_instance_of(PageLayoutHelper).to receive(method)
.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