Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-08-10 18:09:49 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-08-10 18:09:49 +0300
commit70732753863e569f95ed954ca3c41421292f912b (patch)
treef642d44c83ab951fd4581e3c46491784376b9c18
parentbf593ae68b7135bf633484aa3442b7592126b1d2 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue13
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue49
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue36
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/actions.js161
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/getters.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutation_types.js8
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutations.js18
-rw-r--r--app/assets/javascripts/releases/util.js39
-rw-r--r--app/assets/javascripts/static_site_editor/services/templater.js107
-rw-r--r--app/graphql/mutations/boards/issues/issue_move_list.rb91
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/models/concerns/triggerable_hooks.rb3
-rw-r--r--app/models/deployment.rb1
-rw-r--r--app/models/hooks/project_hook.rb3
-rw-r--r--app/services/git/process_ref_changes_service.rb2
-rw-r--r--babel.config.js19
-rw-r--r--changelogs/unreleased/229815-graphql-move-reposition-issue-within-issue-board-list-s.yml5
-rw-r--r--changelogs/unreleased/38834-templater-exclude-pre-existing-codeblocks.yml5
-rw-r--r--changelogs/unreleased/jdb-fix-mlc-comments-on-overview-tab.yml5
-rw-r--r--changelogs/unreleased/leipert-ie11-babel-preset-env.yml5
-rw-r--r--doc/administration/redis/standalone.md51
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql66
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json197
-rw-r--r--doc/api/graphql/reference/index.md10
-rw-r--r--doc/development/changelog.md2
-rw-r--r--doc/development/cicd/index.md7
-rw-r--r--doc/development/cicd/templates.md7
-rw-r--r--doc/development/doc_styleguide.md2
-rw-r--r--doc/development/feature_flags.md4
-rw-r--r--locale/gitlab.pot6
-rw-r--r--package.json4
-rw-r--r--spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js4
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js38
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js52
-rw-r--r--spec/frontend/releases/components/tag_field_spec.js9
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js623
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js4
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js28
-rw-r--r--spec/frontend/releases/util_spec.js85
-rw-r--r--spec/frontend/static_site_editor/services/templater_spec.js18
-rw-r--r--spec/graphql/mutations/boards/issues/issue_move_list_spec.rb90
-rw-r--r--spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb109
-rw-r--r--spec/services/git/process_ref_changes_service_spec.rb12
-rw-r--r--spec/workers/deployments/finished_worker_spec.rb24
-rw-r--r--yarn.lock18
45 files changed, 1546 insertions, 497 deletions
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
index e5f78b1c7de..cace382ccd6 100644
--- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -1,12 +1,10 @@
<script>
-import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
export default {
name: 'ResolveWithIssueButton',
components: {
- Icon,
- GlDeprecatedButton,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -22,13 +20,12 @@ export default {
<template>
<div class="btn-group" role="group">
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip
:href="url"
:title="s__('MergeRequests|Resolve this thread in a new issue')"
class="new-issue-for-discussion discussion-create-issue-btn"
- >
- <icon name="issue-new" />
- </gl-deprecated-button>
+ icon="issue-new"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index adde1e85520..3c84746595d 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -23,7 +23,6 @@ import {
commentLineOptions,
formatLineRange,
} from './multiline_comment_utils';
-import MultilineCommentForm from './multiline_comment_form.vue';
export default {
name: 'NoteableNote',
@@ -34,7 +33,6 @@ export default {
noteActions,
NoteBody,
TimelineEntryItem,
- MultilineCommentForm,
},
mixins: [noteable, resolvable, glFeatureFlagsMixin()],
props: {
@@ -147,14 +145,16 @@ export default {
return getEndLineNumber(this.lineRange);
},
showMultiLineComment() {
- if (!this.glFeatures.multilineComments || !this.discussionRoot) return false;
- if (this.isEditing) return true;
+ if (
+ !this.glFeatures.multilineComments ||
+ !this.discussionRoot ||
+ this.startLineNumber.length === 0 ||
+ this.endLineNumber.length === 0
+ )
+ return false;
return this.line && this.startLineNumber !== this.endLineNumber;
},
- showMultilineCommentForm() {
- return Boolean(this.isEditing && this.note.position && this.diffFile && this.line);
- },
commentLineOptions() {
const sideA = this.line.type === 'new' ? 'right' : 'left';
const sideB = sideA === 'left' ? 'right' : 'left';
@@ -344,28 +344,19 @@ export default {
:data-note-id="note.id"
class="note note-wrapper qa-noteable-note-item"
>
- <div v-if="showMultiLineComment" data-testid="multiline-comment">
- <multiline-comment-form
- v-if="showMultilineCommentForm"
- v-model="commentLineStart"
- :line="line"
- :comment-line-options="commentLineOptions"
- :line-range="note.position.line_range"
- class="gl-mb-3 gl-text-gray-700 gl-pb-3"
- />
- <div
- v-else
- class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
- >
- <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
- <template #startLine>
- <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
- </template>
- <template #endLine>
- <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
- </template>
- </gl-sprintf>
- </div>
+ <div
+ v-if="showMultiLineComment"
+ data-testid="multiline-comment"
+ class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
+ >
+ <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
+ <template #startLine>
+ <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
+ </template>
+ <template #endLine>
+ <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
+ </template>
+ </gl-sprintf>
</div>
<div v-once class="timeline-icon">
<user-avatar-link
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 09fdd9e4438..1710abe72ef 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
@@ -22,9 +21,6 @@ export default {
MilestoneCombobox,
TagField,
},
- directives: {
- autofocusonshow,
- },
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', [
@@ -40,9 +36,9 @@ export default {
'manageMilestonesPath',
'projectId',
]),
- ...mapGetters('detail', ['isValid']),
+ ...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() {
- return !this.isFetchingRelease && !this.fetchError;
+ return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
subtitleText() {
return sprintf(
@@ -86,6 +82,9 @@ export default {
showAssetLinksForm() {
return this.glFeatures.releaseAssetLinkEditing;
},
+ saveButtonLabel() {
+ return this.isExistingRelease ? __('Save changes') : __('Create release');
+ },
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
@@ -102,13 +101,17 @@ export default {
];
},
},
- created() {
- this.fetchRelease();
+ mounted() {
+ // eslint-disable-next-line promise/catch-or-return
+ this.initializeRelease().then(() => {
+ // Focus the first non-disabled input element
+ this.$el.querySelector('input:enabled').focus();
+ });
},
methods: {
...mapActions('detail', [
- 'fetchRelease',
- 'updateRelease',
+ 'initializeRelease',
+ 'saveRelease',
'updateReleaseTitle',
'updateReleaseNotes',
'updateReleaseMilestones',
@@ -119,7 +122,7 @@ export default {
<template>
<div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
- <form v-if="showForm" @submit.prevent="updateRelease()">
+ <form v-if="showForm" @submit.prevent="saveRelease()">
<tag-field />
<gl-form-group>
<label for="release-title">{{ __('Release title') }}</label>
@@ -127,8 +130,6 @@ export default {
id="release-title"
ref="releaseTitleInput"
v-model="releaseTitle"
- v-autofocusonshow
- autofocus
type="text"
class="form-control"
/>
@@ -162,8 +163,8 @@ export default {
data-supports-quick-actions="false"
:aria-label="__('Release notes')"
:placeholder="__('Write your release notes or drag your files here…')"
- @keydown.meta.enter="updateRelease()"
- @keydown.ctrl.enter="updateRelease()"
+ @keydown.meta.enter="saveRelease()"
+ @keydown.ctrl.enter="saveRelease()"
></textarea>
</template>
</markdown-field>
@@ -178,10 +179,11 @@ export default {
category="primary"
variant="success"
type="submit"
- :aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
- >{{ __('Save changes') }}</gl-button
+ data-testid="submit-button"
>
+ {{ saveButtonLabel }}
+ </gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js
index 67d31d37384..2f7d1cb4711 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js
@@ -3,76 +3,114 @@ import api from '~/api';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
-import {
- convertObjectPropsToCamelCase,
- convertObjectPropsToSnakeCase,
-} from '~/lib/utils/common_utils';
-
-export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE);
-export const receiveReleaseSuccess = ({ commit }, data) =>
- commit(types.RECEIVE_RELEASE_SUCCESS, data);
-export const receiveReleaseError = ({ commit }, error) => {
- commit(types.RECEIVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while getting the release details'));
+import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
+
+export const initializeRelease = ({ commit, dispatch, getters }) => {
+ if (getters.isExistingRelease) {
+ // When editing an existing release,
+ // fetch the release object from the API
+ return dispatch('fetchRelease');
+ }
+
+ // When creating a new release, initialize the
+ // store with an empty release object
+ commit(types.INITIALIZE_EMPTY_RELEASE);
+ return Promise.resolve();
};
-export const fetchRelease = ({ dispatch, state }) => {
- dispatch('requestRelease');
+export const fetchRelease = ({ commit, state }) => {
+ commit(types.REQUEST_RELEASE);
return api
.release(state.projectId, state.tagName)
.then(({ data }) => {
- const release = {
- ...data,
- milestones: data.milestones || [],
- };
-
- dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
+ commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data));
})
.catch(error => {
- dispatch('receiveReleaseError', error);
+ commit(types.RECEIVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while getting the release details'));
});
};
export const updateReleaseTagName = ({ commit }, tagName) =>
commit(types.UPDATE_RELEASE_TAG_NAME, tagName);
+
export const updateCreateFrom = ({ commit }, createFrom) =>
commit(types.UPDATE_CREATE_FROM, createFrom);
+
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
+
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
+
export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones);
-export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
-export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
- commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS);
- redirectTo(
- rootState.featureFlags.releaseShowPage ? state.release._links.self : state.releasesPagePath,
- );
+export const addEmptyAssetLink = ({ commit }) => {
+ commit(types.ADD_EMPTY_ASSET_LINK);
};
-export const receiveUpdateReleaseError = ({ commit }, error) => {
- commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while saving the release details'));
+
+export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
+ commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
};
-export const updateRelease = ({ dispatch, state, getters }) => {
- dispatch('requestUpdateRelease');
+export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
+ commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
+};
- const { release } = state;
- const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
+export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
+ commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
+};
+
+export const removeAssetLink = ({ commit }, linkIdToRemove) => {
+ commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
+};
+
+export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => {
+ commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
+ redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath);
+};
+
+export const saveRelease = ({ commit, dispatch, getters }) => {
+ commit(types.REQUEST_SAVE_RELEASE);
- const updatedRelease = convertObjectPropsToSnakeCase(
+ dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
+};
+
+export const createRelease = ({ commit, dispatch, state, getters }) => {
+ const apiJson = releaseToApiJson(
{
- name: release.name,
- description: release.description,
- milestones,
+ ...state.release,
+ assets: {
+ links: getters.releaseLinksToCreate,
+ },
},
- { deep: true },
+ state.createFrom,
);
+ return api
+ .createRelease(state.projectId, apiJson)
+ .then(({ data }) => {
+ dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data));
+ })
+ .catch(error => {
+ commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while creating a new release'));
+ });
+};
+
+export const updateRelease = ({ commit, dispatch, state, getters }) => {
+ const apiJson = releaseToApiJson({
+ ...state.release,
+ assets: {
+ links: getters.releaseLinksToCreate,
+ },
+ });
+
+ let updatedRelease = null;
+
return (
api
- .updateRelease(state.projectId, state.tagName, updatedRelease)
+ .updateRelease(state.projectId, state.tagName, apiJson)
/**
* Currently, we delete all existing links and then
@@ -90,54 +128,31 @@ export const updateRelease = ({ dispatch, state, getters }) => {
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702
* is closed.
*/
+ .then(({ data }) => {
+ // Save this response since we need it later in the Promise chain
+ updatedRelease = data;
- .then(() => {
// Delete all links currently associated with this Release
return Promise.all(
getters.releaseLinksToDelete.map(l =>
- api.deleteReleaseLink(state.projectId, release.tagName, l.id),
+ api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
),
);
})
.then(() => {
// Create a new link for each link in the form
return Promise.all(
- getters.releaseLinksToCreate.map(l =>
- api.createReleaseLink(
- state.projectId,
- release.tagName,
- convertObjectPropsToSnakeCase(l, { deep: true }),
- ),
+ apiJson.assets.links.map(l =>
+ api.createReleaseLink(state.projectId, state.release.tagName, l),
),
);
})
- .then(() => dispatch('receiveUpdateReleaseSuccess'))
+ .then(() => {
+ dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
+ })
.catch(error => {
- dispatch('receiveUpdateReleaseError', error);
+ commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while saving the release details'));
})
);
};
-
-export const navigateToReleasesPage = ({ state }) => {
- redirectTo(state.releasesPagePath);
-};
-
-export const addEmptyAssetLink = ({ commit }) => {
- commit(types.ADD_EMPTY_ASSET_LINK);
-};
-
-export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
- commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
-};
-
-export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
- commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
-};
-
-export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
- commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
-};
-
-export const removeAssetLink = ({ commit }, linkIdToRemove) => {
- commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
-};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/getters.js b/app/assets/javascripts/releases/stores/modules/detail/getters.js
index ffbbc756f39..0d2375566c2 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/getters.js
@@ -6,7 +6,7 @@ import { hasContent } from '~/lib/utils/text_utility';
* `false` if the app is creating a new release.
*/
export const isExistingRelease = state => {
- return Boolean(state.originalRelease);
+ return Boolean(state.tagName);
};
/**
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
index a50086693bf..7784e0cc741 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
@@ -1,3 +1,5 @@
+export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE';
+
export const REQUEST_RELEASE = 'REQUEST_RELEASE';
export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS';
export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
@@ -8,9 +10,9 @@ export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
-export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
-export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
-export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR';
+export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE';
+export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS';
+export const RECEIVE_SAVE_RELEASE_ERROR = 'RECEIVE_SAVE_RELEASE_ERROR';
export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK';
export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL';
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
index 2a8e8a6eb93..155e67b2e55 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
@@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => {
};
export default {
+ [types.INITIALIZE_EMPTY_RELEASE](state) {
+ state.release = {
+ tagName: null,
+ name: '',
+ description: '',
+ milestones: [],
+ assets: {
+ links: [],
+ },
+ };
+ },
+
[types.REQUEST_RELEASE](state) {
state.isFetchingRelease = true;
},
@@ -39,14 +51,14 @@ export default {
state.release.milestones = milestones;
},
- [types.REQUEST_UPDATE_RELEASE](state) {
+ [types.REQUEST_SAVE_RELEASE](state) {
state.isUpdatingRelease = true;
},
- [types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) {
+ [types.RECEIVE_SAVE_RELEASE_SUCCESS](state) {
state.updateError = undefined;
state.isUpdatingRelease = false;
},
- [types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) {
+ [types.RECEIVE_SAVE_RELEASE_ERROR](state, error) {
state.updateError = error;
state.isUpdatingRelease = false;
},
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
new file mode 100644
index 00000000000..efb50dac9cf
--- /dev/null
+++ b/app/assets/javascripts/releases/util.js
@@ -0,0 +1,39 @@
+import {
+ convertObjectPropsToCamelCase,
+ convertObjectPropsToSnakeCase,
+} from '~/lib/utils/common_utils';
+
+/**
+ * Converts a release object into a JSON object that can sent to the public
+ * API to create or update a release.
+ * @param {Object} release The release object to convert
+ * @param {string} createFrom The ref to create a new tag from, if necessary
+ */
+export const releaseToApiJson = (release, createFrom = null) => {
+ const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
+
+ return convertObjectPropsToSnakeCase(
+ {
+ tagName: release.tagName,
+ ref: createFrom,
+ name: release.name,
+ description: release.description,
+ milestones,
+ assets: release.assets,
+ },
+ { deep: true },
+ );
+};
+
+/**
+ * Converts a JSON release object returned by the Release API
+ * into the structure this Vue application can work with.
+ * @param {Object} json The JSON object received from the release API
+ */
+export const apiJsonToRelease = json => {
+ const release = convertObjectPropsToCamelCase(json, { deep: true });
+
+ release.milestones = release.milestones || [];
+
+ return release;
+};
diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js
index 081db60b601..a1c1bb6b8d6 100644
--- a/app/assets/javascripts/static_site_editor/services/templater.js
+++ b/app/assets/javascripts/static_site_editor/services/templater.js
@@ -1,62 +1,89 @@
+/**
+ * The purpose of this file is to modify Markdown source such that templated code (embedded ruby currently) can be temporarily wrapped and unwrapped in codeblocks:
+ * 1. `wrap()`: temporarily wrap in codeblocks (useful for a WYSIWYG editing experience)
+ * 2. `unwrap()`: undo the temporarily wrapped codeblocks (useful for Markdown editing experience and saving edits)
+ *
+ * Without this `templater`, the templated code is otherwise interpreted as Markdown content resulting in loss of spacing, indentation, escape characters, etc.
+ *
+ */
+
const ticks = '```';
const marker = 'sse';
-const prefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26
-const postfix = `\n${ticks}`;
-const flagPrefix = `${marker}-${Date.now()}`;
-const template = `.| |\\t|\\n(?!(\\n|${flagPrefix}))`;
-const templatedRegex = new RegExp(`(^${prefix}(${template})+?${postfix}$)`, 'gm');
-
-const nonErbMarkupRegex = new RegExp(`^((<(?!%).+>){1}(${template})+(</.+>){1})$`, 'gm');
-const embeddedRubyBlockRegex = new RegExp(`(^<%(${template})+%>$)`, 'gm');
-const embeddedRubyInlineRegex = new RegExp(`(^.*[<|&lt;]%(${template})+$)`, 'gm');
-
-// Order is intentional (general to specific) where HTML markup is flagged first, then ERB blocks, then inline ERB
-// Order in combo with the `flag()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example)
-const orderedPatterns = [nonErbMarkupRegex, embeddedRubyBlockRegex, embeddedRubyInlineRegex];
-
-const unwrap = source => {
- let text = source;
- const matches = text.match(templatedRegex);
+const wrapPrefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26
+const wrapPostfix = `\n${ticks}`;
+const markPrefix = `${marker}-${Date.now()}`;
- if (matches) {
- matches.forEach(match => {
- const initial = match.replace(`${prefix}`, '').replace(`${postfix}`, '');
- text = text.replace(match, initial);
- });
- }
+const reHelpers = {
+ template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
+ openTag: '<[a-zA-Z]+.*?>',
+ closeTag: '</.+>',
+};
+const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
+const rePreexistingCodeBlocks = new RegExp(`(^${ticks}.*\\n(.|\\s)+?${ticks}$)`, 'gm');
+const reHtmlMarkup = new RegExp(
+ `^((${reHelpers.openTag}){1}(${reHelpers.template})*(${reHelpers.closeTag}){1})$`,
+ 'gm',
+);
+const reEmbeddedRubyBlock = new RegExp(`(^<%(${reHelpers.template})+%>$)`, 'gm');
+const reEmbeddedRubyInline = new RegExp(`(^.*[<|&lt;]%(${reHelpers.template})+$)`, 'gm');
- return text;
+const patternGroups = {
+ ignore: [rePreexistingCodeBlocks],
+ // Order is intentional (general to specific) where HTML markup is marked first, then ERB blocks, then inline ERB
+ // Order in combo with the `mark()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example)
+ allow: [reHtmlMarkup, reEmbeddedRubyBlock, reEmbeddedRubyInline],
};
-const flag = (source, patterns) => {
+const mark = (source, groups) => {
let text = source;
let id = 0;
const hash = {};
- patterns.forEach(pattern => {
- const matches = text.match(pattern);
- if (matches) {
- matches.forEach(match => {
- const key = `${flagPrefix}${id}`;
- text = text.replace(match, key);
- hash[key] = match;
- id += 1;
- });
- }
+ Object.entries(groups).forEach(([groupKey, group]) => {
+ group.forEach(pattern => {
+ const matches = text.match(pattern);
+ if (matches) {
+ matches.forEach(match => {
+ const key = `${markPrefix}-${groupKey}-${id}`;
+ text = text.replace(match, key);
+ hash[key] = match;
+ id += 1;
+ });
+ }
+ });
});
return { text, hash };
};
-const wrap = source => {
- const { text, hash } = flag(unwrap(source), orderedPatterns);
+const unmark = (text, hash) => {
+ let source = text;
- let wrappedSource = text;
Object.entries(hash).forEach(([key, value]) => {
- wrappedSource = wrappedSource.replace(key, `${prefix}${value}${postfix}`);
+ const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`;
+ source = source.replace(key, newVal);
});
- return wrappedSource;
+ return source;
+};
+
+const unwrap = source => {
+ let text = source;
+ const matches = text.match(reTemplated);
+
+ if (matches) {
+ matches.forEach(match => {
+ const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, '');
+ text = text.replace(match, initial);
+ });
+ }
+
+ return text;
+};
+
+const wrap = source => {
+ const { text, hash } = mark(unwrap(source), patternGroups);
+ return unmark(text, hash);
};
export default { wrap, unwrap };
diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb
new file mode 100644
index 00000000000..d4bf47af4cf
--- /dev/null
+++ b/app/graphql/mutations/boards/issues/issue_move_list.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Boards
+ module Issues
+ class IssueMoveList < Mutations::Issues::Base
+ graphql_name 'IssueMoveList'
+
+ argument :board_id, GraphQL::ID_TYPE,
+ required: true,
+ loads: Types::BoardType,
+ description: 'Global ID of the board that the issue is in'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Project the issue to mutate is in'
+
+ argument :iid, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'IID of the issue to mutate'
+
+ argument :from_list_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'ID of the board list that the issue will be moved from'
+
+ argument :to_list_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'ID of the board list that the issue will be moved to'
+
+ argument :move_before_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'ID of issue before which the current issue will be positioned at'
+
+ argument :move_after_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'ID of issue after which the current issue will be positioned at'
+
+ def ready?(**args)
+ if move_arguments(args).blank?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'At least one of the arguments fromListId, toListId, afterId or beforeId is required'
+ end
+
+ if move_list_arguments(args).one?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'Both fromListId and toListId must be present'
+ end
+
+ super
+ end
+
+ def resolve(board:, **args)
+ raise_resource_not_available_error! unless board
+ authorize_board!(board)
+
+ issue = authorized_find!(project_path: args[:project_path], iid: args[:iid])
+ move_params = { id: issue.id, board_id: board.id }.merge(move_arguments(args))
+
+ move_issue(board, issue, move_params)
+
+ {
+ issue: issue.reset,
+ errors: issue.errors.full_messages
+ }
+ end
+
+ private
+
+ def move_issue(board, issue, move_params)
+ service = ::Boards::Issues::MoveService.new(board.resource_parent, current_user, move_params)
+
+ service.execute(issue)
+ end
+
+ def move_list_arguments(args)
+ args.slice(:from_list_id, :to_list_id)
+ end
+
+ def move_arguments(args)
+ args.slice(:from_list_id, :to_list_id, :move_after_id, :move_before_id)
+ end
+
+ def authorize_board!(board)
+ return if Ability.allowed?(current_user, :read_board, board.resource_parent)
+
+ raise_resource_not_available_error!
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index a2b2ab4a6b2..e3025587ba6 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -14,6 +14,7 @@ module Types
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
+ mount_mutation Mutations::Boards::Issues::IssueMoveList
mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Discussions::ToggleResolve
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
index c52baa0524c..b64a9e4f70b 100644
--- a/app/models/concerns/triggerable_hooks.rb
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -12,7 +12,8 @@ module TriggerableHooks
merge_request_hooks: :merge_requests_events,
job_hooks: :job_events,
pipeline_hooks: :pipeline_events,
- wiki_page_hooks: :wiki_page_events
+ wiki_page_hooks: :wiki_page_events,
+ deployment_hooks: :deployment_events
}.freeze
extend ActiveSupport::Concern
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index aa3e3a8f66d..87587bb5afa 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -148,6 +148,7 @@ class Deployment < ApplicationRecord
def execute_hooks
deployment_data = Gitlab::DataBuilder::Deployment.build(self)
+ project.execute_hooks(deployment_data, :deployment_hooks) if Feature.enabled?(:deployment_webhooks, project)
project.execute_services(deployment_data, :deployment_hooks)
end
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index 71494b6de4d..2d1bdecc770 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -17,7 +17,8 @@ class ProjectHook < WebHook
:merge_request_hooks,
:job_hooks,
:pipeline_hooks,
- :wiki_page_hooks
+ :wiki_page_hooks,
+ :deployment_hooks
]
belongs_to :project
diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb
index 6d1ff97016b..c012c61a337 100644
--- a/app/services/git/process_ref_changes_service.rb
+++ b/app/services/git/process_ref_changes_service.rb
@@ -75,8 +75,6 @@ module Git
end
def merge_request_branches_for(changes)
- return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true)
-
@merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute
end
end
diff --git a/babel.config.js b/babel.config.js
index ea0f75a41ec..64898bfdf50 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -9,8 +9,23 @@ let presets = [
useBuiltIns: 'usage',
corejs: { version: 3, proposals: true },
modules: false,
+ /**
+ * This list of browsers is a conservative first definition, based on
+ * https://docs.gitlab.com/ee/install/requirements.html#supported-web-browsers
+ * with the following reasoning:
+ *
+ * - Edge: Pick the last two major version before the Chrome switch
+ * - Rest: We should support the latest ESR of Firefox: 68, because it used quite a lot.
+ * For the rest, pick browser versions that have a similar age to Firefox 68.
+ *
+ * See also this follow-up epic:
+ * https://gitlab.com/groups/gitlab-org/-/epics/3957
+ */
targets: {
- ie: '11',
+ chrome: '73',
+ edge: '17',
+ firefox: '68',
+ safari: '12',
},
},
],
@@ -22,6 +37,8 @@ const plugins = [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-json-strings',
'@babel/plugin-proposal-private-methods',
+ // See: https://gitlab.com/gitlab-org/gitlab/-/issues/229146
+ '@babel/plugin-transform-arrow-functions',
'lodash',
];
diff --git a/changelogs/unreleased/229815-graphql-move-reposition-issue-within-issue-board-list-s.yml b/changelogs/unreleased/229815-graphql-move-reposition-issue-within-issue-board-list-s.yml
new file mode 100644
index 00000000000..3f6b3b3dac7
--- /dev/null
+++ b/changelogs/unreleased/229815-graphql-move-reposition-issue-within-issue-board-list-s.yml
@@ -0,0 +1,5 @@
+---
+title: GraphQL mutation to move issue within board lists
+merge_request: 38309
+author:
+type: added
diff --git a/changelogs/unreleased/38834-templater-exclude-pre-existing-codeblocks.yml b/changelogs/unreleased/38834-templater-exclude-pre-existing-codeblocks.yml
new file mode 100644
index 00000000000..3de91eec86d
--- /dev/null
+++ b/changelogs/unreleased/38834-templater-exclude-pre-existing-codeblocks.yml
@@ -0,0 +1,5 @@
+---
+title: Add pre-processing step so preexisting codeblocks are preserved prior to flagging content as code in the static site editor's WYSIWYG mode.
+merge_request: 38834
+author:
+type: added
diff --git a/changelogs/unreleased/jdb-fix-mlc-comments-on-overview-tab.yml b/changelogs/unreleased/jdb-fix-mlc-comments-on-overview-tab.yml
new file mode 100644
index 00000000000..a2536202e59
--- /dev/null
+++ b/changelogs/unreleased/jdb-fix-mlc-comments-on-overview-tab.yml
@@ -0,0 +1,5 @@
+---
+title: Fix multiline comment rendering
+merge_request: 38721
+author:
+type: fixed
diff --git a/changelogs/unreleased/leipert-ie11-babel-preset-env.yml b/changelogs/unreleased/leipert-ie11-babel-preset-env.yml
new file mode 100644
index 00000000000..d57317b3f90
--- /dev/null
+++ b/changelogs/unreleased/leipert-ie11-babel-preset-env.yml
@@ -0,0 +1,5 @@
+---
+title: Remove Internet Explorer 11 from babel transpilation
+merge_request: 36840
+author:
+type: removed
diff --git a/doc/administration/redis/standalone.md b/doc/administration/redis/standalone.md
index 12e932dbc5e..ea5a7850244 100644
--- a/doc/administration/redis/standalone.md
+++ b/doc/administration/redis/standalone.md
@@ -15,7 +15,7 @@ is generally stable and can handle many requests, so it is an acceptable
trade off to have only a single instance. See the [reference architectures](../reference_architectures/index.md)
page for an overview of GitLab scaling options.
-## Set up a standalone Redis instance
+## Set up the standalone Redis instance
The steps below are the minimum necessary to configure a Redis server with
Omnibus GitLab:
@@ -28,36 +28,49 @@ Omnibus GitLab:
1. Edit `/etc/gitlab/gitlab.rb` and add the contents:
```ruby
- ## Enable Redis
- redis['enable'] = true
-
- ## Disable all other services
- sidekiq['enable'] = false
- gitlab_workhorse['enable'] = false
- puma['enable'] = false
- postgresql['enable'] = false
- nginx['enable'] = false
- prometheus['enable'] = false
- alertmanager['enable'] = false
- pgbouncer_exporter['enable'] = false
- gitlab_exporter['enable'] = false
- gitaly['enable'] = false
+ ## Enable Redis and disable all other services
+ ## https://docs.gitlab.com/omnibus/roles/
+ roles ['redis_master_role']
+ ## Redis configuration
redis['bind'] = '0.0.0.0'
redis['port'] = 6379
- redis['password'] = 'SECRET_PASSWORD_HERE'
+ redis['password'] = '<redis_password>'
- gitlab_rails['enable'] = false
+ ## Disable automatic database migrations
+ ## Only the primary GitLab application server should handle migrations
+ gitlab_rails['auto_migrate'] = false
```
1. [Reconfigure Omnibus GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
1. Note the Redis node's IP address or hostname, port, and
- Redis password. These will be necessary when configuring the GitLab
- application servers later.
+ Redis password. These will be necessary when [configuring the GitLab
+ application servers](#set-up-the-gitlab-rails-application-instance).
[Advanced configuration options](https://docs.gitlab.com/omnibus/settings/redis.html)
are supported and can be added if needed.
+## Set up the GitLab Rails application instance
+
+On the instance where GitLab is installed:
+
+1. Edit the `/etc/gitlab/gitlab.rb` file and add the following contents:
+
+ ```ruby
+ ## Disable Redis
+ redis['enable'] = false
+
+ gitlab_rails['redis_host'] = 'redis.example.com'
+ gitlab_rails['redis_port'] = 6379
+
+ ## Required if Redis authentication is configured on the Redis node
+ gitlab_rails['redis_password'] = '<redis_password>'
+ ```
+
+1. Save your changes to `/etc/gitlab/gitlab.rb`.
+
+1. [Reconfigure Omnibus GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
+
## Troubleshooting
See the [Redis troubleshooting guide](troubleshooting.md).
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index ea161370762..704f3727434 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -6668,6 +6668,71 @@ type IssueEdge {
}
"""
+Autogenerated input type of IssueMoveList
+"""
+input IssueMoveListInput {
+ """
+ Global ID of the board that the issue is in
+ """
+ boardId: ID!
+
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ ID of the board list that the issue will be moved from
+ """
+ fromListId: ID
+
+ """
+ IID of the issue to mutate
+ """
+ iid: String!
+
+ """
+ ID of issue after which the current issue will be positioned at
+ """
+ moveAfterId: ID
+
+ """
+ ID of issue before which the current issue will be positioned at
+ """
+ moveBeforeId: ID
+
+ """
+ Project the issue to mutate is in
+ """
+ projectPath: ID!
+
+ """
+ ID of the board list that the issue will be moved to
+ """
+ toListId: ID
+}
+
+"""
+Autogenerated return type of IssueMoveList
+"""
+type IssueMoveListPayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Errors encountered during execution of the mutation.
+ """
+ errors: [String!]!
+
+ """
+ The issue after mutation
+ """
+ issue: Issue
+}
+
+"""
Check permissions for the current user on a issue
"""
type IssuePermissions {
@@ -8971,6 +9036,7 @@ type Mutation {
epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
+ issueMoveList(input: IssueMoveListInput!): IssueMoveListPayload
issueSetAssignees(input: IssueSetAssigneesInput!): IssueSetAssigneesPayload
issueSetConfidential(input: IssueSetConfidentialInput!): IssueSetConfidentialPayload
issueSetDueDate(input: IssueSetDueDateInput!): IssueSetDueDatePayload
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 03b9ce920a5..436571c876b 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -18429,6 +18429,176 @@
"possibleTypes": null
},
{
+ "kind": "INPUT_OBJECT",
+ "name": "IssueMoveListInput",
+ "description": "Autogenerated input type of IssueMoveList",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "projectPath",
+ "description": "Project the issue to mutate is in",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "iid",
+ "description": "IID of the issue to mutate",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "boardId",
+ "description": "Global ID of the board that the issue is in",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "fromListId",
+ "description": "ID of the board list that the issue will be moved from",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "toListId",
+ "description": "ID of the board list that the issue will be moved to",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "moveBeforeId",
+ "description": "ID of issue before which the current issue will be positioned at",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "moveAfterId",
+ "description": "ID of issue after which the current issue will be positioned at",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "IssueMoveListPayload",
+ "description": "Autogenerated return type of IssueMoveList",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Errors encountered during execution of the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "issue",
+ "description": "The issue after mutation",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "Issue",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "IssuePermissions",
"description": "Check permissions for the current user on a issue",
@@ -26041,6 +26211,33 @@
"deprecationReason": null
},
{
+ "name": "issueMoveList",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "IssueMoveListInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "IssueMoveListPayload",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "issueSetAssignees",
"description": null,
"args": [
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index f7913056a43..acd8c29301d 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -995,6 +995,16 @@ Represents a Group Member
| `webUrl` | String! | Web URL of the issue |
| `weight` | Int | Weight of the issue |
+## IssueMoveListPayload
+
+Autogenerated return type of IssueMoveList
+
+| Name | Type | Description |
+| --- | ---- | ---------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Errors encountered during execution of the mutation. |
+| `issue` | Issue | The issue after mutation |
+
## IssuePermissions
Check permissions for the current user on a issue
diff --git a/doc/development/changelog.md b/doc/development/changelog.md
index 00a0573a8ba..e83ce40ef60 100644
--- a/doc/development/changelog.md
+++ b/doc/development/changelog.md
@@ -37,6 +37,8 @@ the `author` field. GitLab team members **should not**.
- Any user-facing change **should** have a changelog entry. Example: "GitLab now
uses system fonts for all text."
- Performance improvements **should** have a changelog entry.
+- Changes that need to be documented in the Telemetry [Event Dictionary](telemetry/event_dictionary.md)
+ also require a changelog entry.
- _Any_ contribution from a community member, no matter how small, **may** have
a changelog entry regardless of these guidelines if the contributor wants one.
Example: "Fixed a typo on the search results page."
diff --git a/doc/development/cicd/index.md b/doc/development/cicd/index.md
index e0cca00fd69..5b598a19a6e 100644
--- a/doc/development/cicd/index.md
+++ b/doc/development/cicd/index.md
@@ -1,3 +1,10 @@
+---
+stage: Verify
+group: Continuous Integration
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+type: index, concepts, howto
+---
+
# CI/CD development documentation
Development guides that are specific to CI/CD are listed here.
diff --git a/doc/development/cicd/templates.md b/doc/development/cicd/templates.md
index 3f0b481ccfc..0169ca42ac6 100644
--- a/doc/development/cicd/templates.md
+++ b/doc/development/cicd/templates.md
@@ -1,3 +1,10 @@
+---
+stage: Release
+group: Progressive Delivery
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+type: index, concepts, howto
+---
+
# Development guide for GitLab CI/CD templates
This document explains how to develop [GitLab CI/CD templates](../../ci/examples/README.md).
diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md
index 7d84f8ca86a..4a9f2a626f7 100644
--- a/doc/development/doc_styleguide.md
+++ b/doc/development/doc_styleguide.md
@@ -1,3 +1,5 @@
---
redirect_to: 'documentation/styleguide.md'
---
+
+This document was moved to [another location](documentation/styleguide.md).
diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md
index 6bad91d6287..cff88388ba0 100644
--- a/doc/development/feature_flags.md
+++ b/doc/development/feature_flags.md
@@ -1 +1,5 @@
+---
+redirect_to: 'feature_flags/index.md'
+---
+
This document was moved to [another location](feature_flags/index.md).
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ff6c663b4d9..d739b544fdd 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -7093,6 +7093,9 @@ msgstr ""
msgid "Create project label"
msgstr ""
+msgid "Create release"
+msgstr ""
+
msgid "Create requirement"
msgstr ""
@@ -20099,6 +20102,9 @@ msgstr ""
msgid "Releases|New Release"
msgstr ""
+msgid "Release|Something went wrong while creating a new release"
+msgstr ""
+
msgid "Release|Something went wrong while getting the release details"
msgstr ""
diff --git a/package.json b/package.json
index 2d8ba1d1463..17d9f47e763 100644
--- a/package.json
+++ b/package.json
@@ -42,8 +42,8 @@
"@babel/plugin-syntax-import-meta": "^7.10.1",
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
- "@gitlab/svgs": "1.157.0",
- "@gitlab/ui": "18.1.0",
+ "@gitlab/svgs": "1.158.0",
+ "@gitlab/ui": "18.3.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-1",
"@sentry/browser": "^5.10.2",
diff --git a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js
index e62fb5db2c0..4348445f7ca 100644
--- a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js
+++ b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js
@@ -1,4 +1,4 @@
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { TEST_HOST } from 'spec/test_constants';
import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue';
@@ -23,7 +23,7 @@ describe('ResolveWithIssueButton', () => {
});
it('it should have a link with the provided link property as href', () => {
- const button = wrapper.find(GlDeprecatedButton);
+ const button = wrapper.find(GlButton);
expect(button.attributes().href).toBe(url);
});
diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js
index b0b43075c3d..a08e86d92d3 100644
--- a/spec/frontend/notes/components/noteable_note_spec.js
+++ b/spec/frontend/notes/components/noteable_note_spec.js
@@ -83,18 +83,34 @@ describe('issue_note', () => {
});
});
- it('should render multiline comment if editing discussion root', () => {
- wrapper.setProps({ discussionRoot: true });
- wrapper.vm.isEditing = true;
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findMultilineComment().exists()).toBe(true);
+ it('should only render if it has everything it needs', () => {
+ const position = {
+ line_range: {
+ start: {
+ line_code: 'abc_1_1',
+ type: null,
+ old_line: '',
+ new_line: '',
+ },
+ end: {
+ line_code: 'abc_2_2',
+ type: null,
+ old_line: '2',
+ new_line: '2',
+ },
+ },
+ };
+ const line = {
+ line_code: 'abc_1_1',
+ type: null,
+ old_line: '1',
+ new_line: '1',
+ };
+ wrapper.setProps({
+ note: { ...note, position },
+ discussionRoot: true,
+ line,
});
- });
-
- it('should only render multiline comment form if it has everything it needs', () => {
- wrapper.setProps({ line: { line_code: '' } });
- wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(false);
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index 2b73fb7f30f..4e0fa9d265c 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -27,8 +27,8 @@ describe('Release edit/new component', () => {
};
actions = {
- fetchRelease: jest.fn(),
- updateRelease: jest.fn(),
+ initializeRelease: jest.fn(),
+ saveRelease: jest.fn(),
addEmptyAssetLink: jest.fn(),
};
@@ -64,6 +64,8 @@ describe('Release edit/new component', () => {
glFeatures: featureFlags,
},
});
+
+ wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus'));
};
beforeEach(() => {
@@ -87,8 +89,18 @@ describe('Release edit/new component', () => {
factory();
});
- it('calls fetchRelease when the component is created', () => {
- expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
+ it('calls initializeRelease when the component is created', () => {
+ expect(actions.initializeRelease).toHaveBeenCalledTimes(1);
+ });
+
+ it('focuses the first non-disabled input element once the page is shown', () => {
+ const firstEnabledInput = wrapper.element.querySelector('input:enabled');
+ const allInputs = wrapper.element.querySelectorAll('input');
+
+ allInputs.forEach(input => {
+ const expectedFocusCalls = input === firstEnabledInput ? 1 : 0;
+ expect(input.focus).toHaveBeenCalledTimes(expectedFocusCalls);
+ });
});
it('renders the description text at the top of the page', () => {
@@ -109,9 +121,9 @@ describe('Release edit/new component', () => {
expect(findSubmitButton().attributes('type')).toBe('submit');
});
- it('calls updateRelease when the form is submitted', () => {
+ it('calls saveRelease when the form is submitted', () => {
wrapper.find('form').trigger('submit');
- expect(actions.updateRelease).toHaveBeenCalledTimes(1);
+ expect(actions.saveRelease).toHaveBeenCalledTimes(1);
});
});
@@ -143,6 +155,34 @@ describe('Release edit/new component', () => {
});
});
+ describe('when creating a new release', () => {
+ beforeEach(() => {
+ factory({
+ store: {
+ modules: {
+ detail: {
+ getters: {
+ isExistingRelease: () => false,
+ },
+ },
+ },
+ },
+ });
+ });
+
+ it('renders the submit button with the text "Create release"', () => {
+ expect(findSubmitButton().text()).toBe('Create release');
+ });
+ });
+
+ describe('when editing an existing release', () => {
+ beforeEach(factory);
+
+ it('renders the submit button with the text "Save changes"', () => {
+ expect(findSubmitButton().text()).toBe('Save changes');
+ });
+ });
+
describe('asset links form', () => {
const findAssetLinksForm = () => wrapper.find(AssetLinksForm);
diff --git a/spec/frontend/releases/components/tag_field_spec.js b/spec/frontend/releases/components/tag_field_spec.js
index aaec685e822..c7909a2369b 100644
--- a/spec/frontend/releases/components/tag_field_spec.js
+++ b/spec/frontend/releases/components/tag_field_spec.js
@@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => {
let store;
let wrapper;
- const createComponent = ({ originalRelease }) => {
+ const createComponent = ({ tagName }) => {
store = createStore({
modules: {
detail: createDetailModule({}),
},
});
- store.state.detail.originalRelease = originalRelease;
+ store.state.detail.tagName = tagName;
wrapper = shallowMount(TagField, { store });
};
@@ -31,8 +31,7 @@ describe('releases/components/tag_field', () => {
describe('when an existing release is being edited', () => {
beforeEach(() => {
- const originalRelease = { name: 'Version 1.0' };
- createComponent({ originalRelease });
+ createComponent({ tagName: 'v1.0' });
});
it('renders the TagFieldExisting component', () => {
@@ -46,7 +45,7 @@ describe('releases/components/tag_field', () => {
describe('when a new release is being created', () => {
beforeEach(() => {
- createComponent({ originalRelease: null });
+ createComponent({ tagName: null });
});
it('renders the TagFieldNew component', () => {
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index 83ad41fb9ee..5227bf234d6 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -1,7 +1,7 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import { cloneDeep, merge } from 'lodash';
+import { cloneDeep } from 'lodash';
import * as actions from '~/releases/stores/modules/detail/actions';
import * as types from '~/releases/stores/modules/detail/mutation_types';
import { release as originalRelease } from '../../../mock_data';
@@ -10,7 +10,9 @@ import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import api from '~/api';
+import httpStatus from '~/lib/utils/http_status';
import { ASSET_LINK_TYPE } from '~/releases/constants';
+import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
jest.mock('~/flash', () => jest.fn());
@@ -25,15 +27,26 @@ describe('Release detail actions', () => {
let mock;
let error;
+ const setupState = (updates = {}) => {
+ const getters = {
+ isExistingRelease: true,
+ };
+
+ state = {
+ ...createState({
+ projectId: '18',
+ tagName: release.tag_name,
+ releasesPagePath: 'path/to/releases/page',
+ markdownDocsPath: 'path/to/markdown/docs',
+ markdownPreviewPath: 'path/to/markdown/preview',
+ updateReleaseApiDocsPath: 'path/to/api/docs',
+ }),
+ ...getters,
+ ...updates,
+ };
+ };
+
beforeEach(() => {
- state = createState({
- projectId: '18',
- tagName: 'v1.3',
- releasesPagePath: 'path/to/releases/page',
- markdownDocsPath: 'path/to/markdown/docs',
- markdownPreviewPath: 'path/to/markdown/preview',
- updateReleaseApiDocsPath: 'path/to/api/docs',
- });
release = cloneDeep(originalRelease);
mock = new MockAdapter(axios);
gon.api_version = 'v4';
@@ -45,302 +58,424 @@ describe('Release detail actions', () => {
mock.restore();
});
- describe('requestRelease', () => {
- it(`commits ${types.REQUEST_RELEASE}`, () =>
- testAction(actions.requestRelease, undefined, state, [{ type: types.REQUEST_RELEASE }]));
- });
+ describe('when creating a new release', () => {
+ beforeEach(() => {
+ setupState({ isExistingRelease: false });
+ });
- describe('receiveReleaseSuccess', () => {
- it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () =>
- testAction(actions.receiveReleaseSuccess, release, state, [
- { type: types.RECEIVE_RELEASE_SUCCESS, payload: release },
- ]));
+ describe('initializeRelease', () => {
+ it(`commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => {
+ testAction(actions.initializeRelease, undefined, state, [
+ { type: types.INITIALIZE_EMPTY_RELEASE },
+ ]);
+ });
+ });
+
+ describe('saveRelease', () => {
+ it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => {
+ testAction(
+ actions.saveRelease,
+ undefined,
+ state,
+ [{ type: types.REQUEST_SAVE_RELEASE }],
+ [{ type: 'createRelease' }],
+ );
+ });
+ });
});
- describe('receiveReleaseError', () => {
- it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () =>
- testAction(actions.receiveReleaseError, error, state, [
- { type: types.RECEIVE_RELEASE_ERROR, payload: error },
- ]));
+ describe('when editing an existing release', () => {
+ beforeEach(setupState);
- it('shows a flash with an error message', () => {
- actions.receiveReleaseError({ commit: jest.fn() }, error);
+ describe('initializeRelease', () => {
+ it('dispatches "fetchRelease"', () => {
+ testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]);
+ });
+ });
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(
- 'Something went wrong while getting the release details',
- );
+ describe('saveRelease', () => {
+ it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => {
+ testAction(
+ actions.saveRelease,
+ undefined,
+ state,
+ [{ type: types.REQUEST_SAVE_RELEASE }],
+ [{ type: 'updateRelease' }],
+ );
+ });
});
});
- describe('fetchRelease', () => {
- let getReleaseUrl;
+ describe('actions that behave the same whether creating a new release or editing an existing release', () => {
+ beforeEach(setupState);
- beforeEach(() => {
- state.projectId = '18';
- state.tagName = 'v1.3';
- getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
- });
+ describe('fetchRelease', () => {
+ let getReleaseUrl;
- it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => {
- mock.onGet(getReleaseUrl).replyOnce(200, release);
-
- return testAction(
- actions.fetchRelease,
- undefined,
- state,
- [],
- [
- { type: 'requestRelease' },
- {
- type: 'receiveReleaseSuccess',
- payload: convertObjectPropsToCamelCase(release, { deep: true }),
- },
- ],
- );
- });
+ beforeEach(() => {
+ getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
+ });
+
+ describe('when the network request to the Release API is successful', () => {
+ beforeEach(() => {
+ mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release);
+ });
+
+ it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => {
+ return testAction(actions.fetchRelease, undefined, state, [
+ {
+ type: types.REQUEST_RELEASE,
+ },
+ {
+ type: types.RECEIVE_RELEASE_SUCCESS,
+ payload: apiJsonToRelease(release, { deep: true }),
+ },
+ ]);
+ });
+ });
- it(`dispatches requestRelease and receiveReleaseError with an error object`, () => {
- mock.onGet(getReleaseUrl).replyOnce(500);
+ describe('when the network request to the Release API fails', () => {
+ beforeEach(() => {
+ mock.onGet(getReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
+ });
+
+ it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => {
+ return testAction(actions.fetchRelease, undefined, state, [
+ {
+ type: types.REQUEST_RELEASE,
+ },
+ {
+ type: types.RECEIVE_RELEASE_ERROR,
+ payload: expect.any(Error),
+ },
+ ]);
+ });
- return testAction(
- actions.fetchRelease,
- undefined,
- state,
- [],
- [{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }],
- );
+ it(`shows a flash message`, () => {
+ return actions.fetchRelease({ commit: jest.fn(), state }).then(() => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(
+ 'Something went wrong while getting the release details',
+ );
+ });
+ });
+ });
});
- });
- describe('updateReleaseTagName', () => {
- it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => {
- const newTag = 'updated-tag-name';
- return testAction(actions.updateReleaseTagName, newTag, state, [
- { type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag },
- ]);
+ describe('updateReleaseTagName', () => {
+ it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => {
+ const newTag = 'updated-tag-name';
+ return testAction(actions.updateReleaseTagName, newTag, state, [
+ { type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag },
+ ]);
+ });
});
- });
- describe('updateCreateFrom', () => {
- it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => {
- const newRef = 'my-feature-branch';
- return testAction(actions.updateCreateFrom, newRef, state, [
- { type: types.UPDATE_CREATE_FROM, payload: newRef },
- ]);
+ describe('updateCreateFrom', () => {
+ it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => {
+ const newRef = 'my-feature-branch';
+ return testAction(actions.updateCreateFrom, newRef, state, [
+ { type: types.UPDATE_CREATE_FROM, payload: newRef },
+ ]);
+ });
});
- });
- describe('updateReleaseTitle', () => {
- it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => {
- const newTitle = 'The new release title';
- return testAction(actions.updateReleaseTitle, newTitle, state, [
- { type: types.UPDATE_RELEASE_TITLE, payload: newTitle },
- ]);
+ describe('updateReleaseTitle', () => {
+ it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => {
+ const newTitle = 'The new release title';
+ return testAction(actions.updateReleaseTitle, newTitle, state, [
+ { type: types.UPDATE_RELEASE_TITLE, payload: newTitle },
+ ]);
+ });
});
- });
- describe('updateReleaseNotes', () => {
- it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => {
- const newReleaseNotes = 'The new release notes';
- return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [
- { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes },
- ]);
+ describe('updateReleaseNotes', () => {
+ it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => {
+ const newReleaseNotes = 'The new release notes';
+ return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [
+ { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes },
+ ]);
+ });
});
- });
- describe('updateAssetLinkUrl', () => {
- it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => {
- const params = {
- linkIdToUpdate: 2,
- newUrl: 'https://example.com/updated',
- };
+ describe('updateReleaseMilestones', () => {
+ it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
+ const newReleaseMilestones = ['v0.0', 'v0.1'];
+ return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
+ { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
+ ]);
+ });
+ });
- return testAction(actions.updateAssetLinkUrl, params, state, [
- { type: types.UPDATE_ASSET_LINK_URL, payload: params },
- ]);
+ describe('addEmptyAssetLink', () => {
+ it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => {
+ return testAction(actions.addEmptyAssetLink, undefined, state, [
+ { type: types.ADD_EMPTY_ASSET_LINK },
+ ]);
+ });
});
- });
- describe('updateAssetLinkName', () => {
- it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => {
- const params = {
- linkIdToUpdate: 2,
- newName: 'Updated link name',
- };
+ describe('updateAssetLinkUrl', () => {
+ it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => {
+ const params = {
+ linkIdToUpdate: 2,
+ newUrl: 'https://example.com/updated',
+ };
- return testAction(actions.updateAssetLinkName, params, state, [
- { type: types.UPDATE_ASSET_LINK_NAME, payload: params },
- ]);
+ return testAction(actions.updateAssetLinkUrl, params, state, [
+ { type: types.UPDATE_ASSET_LINK_URL, payload: params },
+ ]);
+ });
});
- });
- describe('updateAssetLinkType', () => {
- it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => {
- const params = {
- linkIdToUpdate: 2,
- newType: ASSET_LINK_TYPE.RUNBOOK,
- };
+ describe('updateAssetLinkName', () => {
+ it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => {
+ const params = {
+ linkIdToUpdate: 2,
+ newName: 'Updated link name',
+ };
- return testAction(actions.updateAssetLinkType, params, state, [
- { type: types.UPDATE_ASSET_LINK_TYPE, payload: params },
- ]);
+ return testAction(actions.updateAssetLinkName, params, state, [
+ { type: types.UPDATE_ASSET_LINK_NAME, payload: params },
+ ]);
+ });
});
- });
- describe('removeAssetLink', () => {
- it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => {
- const idToRemove = 2;
- return testAction(actions.removeAssetLink, idToRemove, state, [
- { type: types.REMOVE_ASSET_LINK, payload: idToRemove },
- ]);
+ describe('updateAssetLinkType', () => {
+ it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => {
+ const params = {
+ linkIdToUpdate: 2,
+ newType: ASSET_LINK_TYPE.RUNBOOK,
+ };
+
+ return testAction(actions.updateAssetLinkType, params, state, [
+ { type: types.UPDATE_ASSET_LINK_TYPE, payload: params },
+ ]);
+ });
});
- });
- describe('updateReleaseMilestones', () => {
- it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
- const newReleaseMilestones = ['v0.0', 'v0.1'];
- return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
- { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
- ]);
+ describe('removeAssetLink', () => {
+ it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => {
+ const idToRemove = 2;
+ return testAction(actions.removeAssetLink, idToRemove, state, [
+ { type: types.REMOVE_ASSET_LINK, payload: idToRemove },
+ ]);
+ });
});
- });
- describe('requestUpdateRelease', () => {
- it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
- testAction(actions.requestUpdateRelease, undefined, state, [
- { type: types.REQUEST_UPDATE_RELEASE },
- ]));
- });
+ describe('receiveSaveReleaseSuccess', () => {
+ it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () =>
+ testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
+ { type: types.RECEIVE_SAVE_RELEASE_SUCCESS },
+ ]));
- describe('receiveUpdateReleaseSuccess', () => {
- it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () =>
- testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
- { type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
- ]));
+ describe('when the releaseShowPage feature flag is enabled', () => {
+ beforeEach(() => {
+ const rootState = { featureFlags: { releaseShowPage: true } };
+ actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
+ });
- it('redirects to the releases page if releaseShowPage feature flag is enabled', () => {
- const rootState = { featureFlags: { releaseShowPage: true } };
- const updatedState = merge({}, state, {
- releasesPagePath: 'path/to/releases/page',
- release: {
- _links: {
- self: 'path/to/self',
- },
- },
+ it("redirects to the release's dedicated page", () => {
+ expect(redirectTo).toHaveBeenCalledTimes(1);
+ expect(redirectTo).toHaveBeenCalledWith(release._links.self);
+ });
});
- actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState });
+ describe('when the releaseShowPage feature flag is disabled', () => {
+ beforeEach(() => {
+ const rootState = { featureFlags: { releaseShowPage: false } };
+ actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
+ });
- expect(redirectTo).toHaveBeenCalledTimes(1);
- expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self);
+ it("redirects to the project's main Releases page", () => {
+ expect(redirectTo).toHaveBeenCalledTimes(1);
+ expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath);
+ });
+ });
});
- describe('when the releaseShowPage feature flag is disabled', () => {});
- });
-
- describe('receiveUpdateReleaseError', () => {
- it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () =>
- testAction(actions.receiveUpdateReleaseError, error, state, [
- { type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error },
- ]));
+ describe('createRelease', () => {
+ let createReleaseUrl;
+ let releaseLinksToCreate;
- it('shows a flash with an error message', () => {
- actions.receiveUpdateReleaseError({ commit: jest.fn() }, error);
+ beforeEach(() => {
+ const camelCasedRelease = convertObjectPropsToCamelCase(release);
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(
- 'Something went wrong while saving the release details',
- );
- });
- });
+ releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1);
- describe('updateRelease', () => {
- let getters;
- let dispatch;
- let callOrder;
+ setupState({
+ release: camelCasedRelease,
+ releaseLinksToCreate,
+ });
- beforeEach(() => {
- state.release = convertObjectPropsToCamelCase(release);
- state.projectId = '18';
- state.tagName = state.release.tagName;
+ createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`;
+ });
- getters = {
- releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
- releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
- };
+ describe('when the network request to the Release API is successful', () => {
+ beforeEach(() => {
+ const expectedRelease = releaseToApiJson({
+ ...state.release,
+ assets: {
+ links: releaseLinksToCreate,
+ },
+ });
- dispatch = jest.fn();
+ mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release);
+ });
- callOrder = [];
- jest.spyOn(api, 'updateRelease').mockImplementation(() => {
- callOrder.push('updateRelease');
- return Promise.resolve();
- });
- jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
- callOrder.push('deleteReleaseLink');
- return Promise.resolve();
- });
- jest.spyOn(api, 'createReleaseLink').mockImplementation(() => {
- callOrder.push('createReleaseLink');
- return Promise.resolve();
+ it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => {
+ return testAction(
+ actions.createRelease,
+ undefined,
+ state,
+ [],
+ [
+ {
+ type: 'receiveSaveReleaseSuccess',
+ payload: apiJsonToRelease(release, { deep: true }),
+ },
+ ],
+ );
+ });
});
- });
- it('dispatches requestUpdateRelease and receiveUpdateReleaseSuccess', () => {
- return actions.updateRelease({ dispatch, state, getters }).then(() => {
- expect(dispatch.mock.calls).toEqual([
- ['requestUpdateRelease'],
- ['receiveUpdateReleaseSuccess'],
- ]);
+ describe('when the network request to the Release API fails', () => {
+ beforeEach(() => {
+ mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
+ });
+
+ it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => {
+ return testAction(actions.createRelease, undefined, state, [
+ {
+ type: types.RECEIVE_SAVE_RELEASE_ERROR,
+ payload: expect.any(Error),
+ },
+ ]);
+ });
+
+ it(`shows a flash message`, () => {
+ return actions
+ .createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} })
+ .then(() => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(
+ 'Something went wrong while creating a new release',
+ );
+ });
+ });
});
});
- it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
- jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
+ describe('updateRelease', () => {
+ let getters;
+ let dispatch;
+ let commit;
+ let callOrder;
+
+ beforeEach(() => {
+ getters = {
+ releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
+ releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
+ };
+
+ setupState({
+ release: convertObjectPropsToCamelCase(release),
+ ...getters,
+ });
- return actions.updateRelease({ dispatch, state, getters }).then(() => {
- expect(dispatch.mock.calls).toEqual([
- ['requestUpdateRelease'],
- ['receiveUpdateReleaseError', error],
- ]);
+ dispatch = jest.fn();
+ commit = jest.fn();
+
+ callOrder = [];
+ jest.spyOn(api, 'updateRelease').mockImplementation(() => {
+ callOrder.push('updateRelease');
+ return Promise.resolve({ data: release });
+ });
+ jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
+ callOrder.push('deleteReleaseLink');
+ return Promise.resolve();
+ });
+ jest.spyOn(api, 'createReleaseLink').mockImplementation(() => {
+ callOrder.push('createReleaseLink');
+ return Promise.resolve();
+ });
});
- });
- it('updates the Release, then deletes all existing links, and then recreates new links', () => {
- return actions.updateRelease({ dispatch, state, getters }).then(() => {
- expect(callOrder).toEqual([
- 'updateRelease',
- 'deleteReleaseLink',
- 'deleteReleaseLink',
- 'createReleaseLink',
- 'createReleaseLink',
- ]);
+ describe('when the network request to the Release API is successful', () => {
+ it('dispatches receiveSaveReleaseSuccess', () => {
+ return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
+ expect(dispatch.mock.calls).toEqual([
+ ['receiveSaveReleaseSuccess', apiJsonToRelease(release)],
+ ]);
+ });
+ });
- expect(api.updateRelease.mock.calls).toEqual([
- [
- state.projectId,
- state.tagName,
- {
- name: state.release.name,
- description: state.release.description,
- milestones: state.release.milestones.map(milestone => milestone.title),
- },
- ],
- ]);
+ it('updates the Release, then deletes all existing links, and then recreates new links', () => {
+ return actions.updateRelease({ dispatch, state, getters }).then(() => {
+ expect(callOrder).toEqual([
+ 'updateRelease',
+ 'deleteReleaseLink',
+ 'deleteReleaseLink',
+ 'createReleaseLink',
+ 'createReleaseLink',
+ ]);
+
+ expect(api.updateRelease.mock.calls).toEqual([
+ [
+ state.projectId,
+ state.tagName,
+ releaseToApiJson({
+ ...state.release,
+ assets: {
+ links: getters.releaseLinksToCreate,
+ },
+ }),
+ ],
+ ]);
+
+ expect(api.deleteReleaseLink).toHaveBeenCalledTimes(
+ getters.releaseLinksToDelete.length,
+ );
+ getters.releaseLinksToDelete.forEach(link => {
+ expect(api.deleteReleaseLink).toHaveBeenCalledWith(
+ state.projectId,
+ state.tagName,
+ link.id,
+ );
+ });
+
+ expect(api.createReleaseLink).toHaveBeenCalledTimes(
+ getters.releaseLinksToCreate.length,
+ );
+ getters.releaseLinksToCreate.forEach(link => {
+ expect(api.createReleaseLink).toHaveBeenCalledWith(
+ state.projectId,
+ state.tagName,
+ link,
+ );
+ });
+ });
+ });
+ });
- expect(api.deleteReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToDelete.length);
- getters.releaseLinksToDelete.forEach(link => {
- expect(api.deleteReleaseLink).toHaveBeenCalledWith(
- state.projectId,
- state.tagName,
- link.id,
- );
+ describe('when the network request to the Release API fails', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
+ });
+
+ it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
+ return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
+ expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]);
+ });
});
- expect(api.createReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToCreate.length);
- getters.releaseLinksToCreate.forEach(link => {
- expect(api.createReleaseLink).toHaveBeenCalledWith(state.projectId, state.tagName, link);
+ it('shows a flash message', () => {
+ return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(
+ 'Something went wrong while saving the release details',
+ );
+ });
});
});
});
diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js
index d8776ef44d2..b000b539ad7 100644
--- a/spec/frontend/releases/stores/modules/detail/getters_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js
@@ -3,13 +3,13 @@ import * as getters from '~/releases/stores/modules/detail/getters';
describe('Release detail getters', () => {
describe('isExistingRelease', () => {
it('returns true if the release is an existing release that already exists in the database', () => {
- const state = { originalRelease: { name: 'The first release' } };
+ const state = { tagName: 'test-tag-name' };
expect(getters.isExistingRelease(state)).toBe(true);
});
it('returns false if the release is a new release that has not yet been saved to the database', () => {
- const state = { originalRelease: null };
+ const state = { tagName: null };
expect(getters.isExistingRelease(state)).toBe(false);
});
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index d9b7ec184ed..cd7c6b7d275 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -21,6 +21,22 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease);
});
+ describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => {
+ it('set state.release to an empty release object', () => {
+ mutations[types.INITIALIZE_EMPTY_RELEASE](state);
+
+ expect(state.release).toEqual({
+ tagName: null,
+ name: '',
+ description: '',
+ milestones: [],
+ assets: {
+ links: [],
+ },
+ });
+ });
+ });
+
describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state);
@@ -96,17 +112,17 @@ describe('Release detail mutations', () => {
});
});
- describe(`${types.REQUEST_UPDATE_RELEASE}`, () => {
+ describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
- mutations[types.REQUEST_UPDATE_RELEASE](state);
+ mutations[types.REQUEST_SAVE_RELEASE](state);
expect(state.isUpdatingRelease).toBe(true);
});
});
- describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => {
+ describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
- mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
+ mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release);
expect(state.updateError).toBeUndefined();
@@ -114,10 +130,10 @@ describe('Release detail mutations', () => {
});
});
- describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => {
+ describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
- mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error);
+ mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error);
expect(state.isUpdatingRelease).toBe(false);
diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js
new file mode 100644
index 00000000000..921da34d62a
--- /dev/null
+++ b/spec/frontend/releases/util_spec.js
@@ -0,0 +1,85 @@
+import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
+
+describe('releases/util.js', () => {
+ describe('releaseToApiJson', () => {
+ it('converts a release JavaScript object into JSON that the Release API can accept', () => {
+ const release = {
+ tagName: 'tag-name',
+ name: 'Release name',
+ description: 'Release description',
+ milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }],
+ assets: {
+ links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }],
+ },
+ };
+
+ const expectedJson = {
+ tag_name: 'tag-name',
+ ref: null,
+ name: 'Release name',
+ description: 'Release description',
+ milestones: ['13.2', '13.3'],
+ assets: {
+ links: [{ url: 'https://gitlab.example.com/link', link_type: 'other' }],
+ },
+ };
+
+ expect(releaseToApiJson(release)).toEqual(expectedJson);
+ });
+
+ describe('when createFrom is provided', () => {
+ it('adds the provided createFrom ref to the JSON as a "ref" property', () => {
+ const createFrom = 'main';
+
+ const release = {};
+
+ const expectedJson = {
+ ref: createFrom,
+ };
+
+ expect(releaseToApiJson(release, createFrom)).toMatchObject(expectedJson);
+ });
+ });
+
+ describe('when release.milestones is falsy', () => {
+ it('includes a "milestone" property in the returned result as an empty array', () => {
+ const release = {};
+
+ const expectedJson = {
+ milestones: [],
+ };
+
+ expect(releaseToApiJson(release)).toMatchObject(expectedJson);
+ });
+ });
+ });
+
+ describe('apiJsonToRelease', () => {
+ it('converts JSON received from the Release API into an object usable by the Vue application', () => {
+ const json = {
+ tag_name: 'tag-name',
+ assets: {
+ links: [
+ {
+ link_type: 'other',
+ },
+ ],
+ },
+ };
+
+ const expectedRelease = {
+ tagName: 'tag-name',
+ assets: {
+ links: [
+ {
+ linkType: 'other',
+ },
+ ],
+ },
+ milestones: [],
+ };
+
+ expect(apiJsonToRelease(json)).toEqual(expectedRelease);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/services/templater_spec.js b/spec/frontend/static_site_editor/services/templater_spec.js
index 228c91c006d..1e7ae872b7e 100644
--- a/spec/frontend/static_site_editor/services/templater_spec.js
+++ b/spec/frontend/static_site_editor/services/templater_spec.js
@@ -30,6 +30,15 @@ Below this line is a block of HTML.
<h1>Heading</h1>
<p>Some paragraph...</p>
</div>
+
+Below this line is a codeblock of the same HTML that should be ignored and preserved.
+
+\`\`\` html
+<div>
+ <h1>Heading</h1>
+ <p>Some paragraph...</p>
+</div>
+\`\`\`
`;
const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example.
@@ -69,6 +78,15 @@ Below this line is a block of HTML.
<p>Some paragraph...</p>
</div>
\`\`\`
+
+Below this line is a codeblock of the same HTML that should be ignored and preserved.
+
+\`\`\` html
+<div>
+ <h1>Heading</h1>
+ <p>Some paragraph...</p>
+</div>
+\`\`\`
`;
it.each`
diff --git a/spec/graphql/mutations/boards/issues/issue_move_list_spec.rb b/spec/graphql/mutations/boards/issues/issue_move_list_spec.rb
new file mode 100644
index 00000000000..71c43ed826c
--- /dev/null
+++ b/spec/graphql/mutations/boards/issues/issue_move_list_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Boards::Issues::IssueMoveList do
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:board) { create(:board, group: group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:development) { create(:label, project: project, name: 'Development') }
+ let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
+ let_it_be(:list1) { create(:list, board: board, label: development, position: 0) }
+ let_it_be(:list2) { create(:list, board: board, label: testing, position: 1) }
+ let_it_be(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
+ let_it_be(:existing_issue1) { create(:labeled_issue, project: project, labels: [testing], relative_position: 10) }
+ let_it_be(:existing_issue2) { create(:labeled_issue, project: project, labels: [testing], relative_position: 50) }
+
+ let(:current_user) { user }
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+ let(:params) { { board: board, project_path: project.full_path, iid: issue1.iid } }
+ let(:move_params) do
+ {
+ from_list_id: list1.id,
+ to_list_id: list2.id,
+ move_before_id: existing_issue2.id,
+ move_after_id: existing_issue1.id
+ }
+ end
+
+ before_all do
+ group.add_maintainer(user)
+ group.add_guest(guest)
+ end
+
+ subject do
+ mutation.resolve(params.merge(move_params))
+ end
+
+ describe '#ready?' do
+ it 'raises an error if required arguments are missing' do
+ expect { mutation.ready?(params) }
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "At least one of the arguments " \
+ "fromListId, toListId, afterId or beforeId is required")
+ end
+
+ it 'raises an error if only one of fromListId and toListId is present' do
+ expect { mutation.ready?(params.merge(from_list_id: list1.id)) }
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError,
+ 'Both fromListId and toListId must be present'
+ )
+ end
+ end
+
+ describe '#resolve' do
+ context 'when user have access to resources' do
+ it 'moves and repositions issue' do
+ subject
+
+ expect(issue1.reload.labels).to eq([testing])
+ expect(issue1.relative_position).to be < existing_issue2.relative_position
+ expect(issue1.relative_position).to be > existing_issue1.relative_position
+ end
+ end
+
+ context 'when user have no access to resources' do
+ shared_examples 'raises a resource not available error' do
+ it { expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) }
+ end
+
+ context 'when user cannot update issue' do
+ let(:current_user) { guest }
+
+ it_behaves_like 'raises a resource not available error'
+ end
+
+ context 'when user cannot access board' do
+ let(:board) { create(:board, group: create(:group, :private)) }
+
+ it_behaves_like 'raises a resource not available error'
+ end
+
+ context 'when passing board_id as nil' do
+ let(:board) { nil }
+
+ it_behaves_like 'raises a resource not available error'
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb b/spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb
new file mode 100644
index 00000000000..e24ab0b07f2
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Reposition and move issue within board lists' do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:board) { create(:board, group: group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:development) { create(:label, project: project, name: 'Development') }
+ let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
+ let_it_be(:list1) { create(:list, board: board, label: development, position: 0) }
+ let_it_be(:list2) { create(:list, board: board, label: testing, position: 1) }
+ let_it_be(:existing_issue1) { create(:labeled_issue, project: project, labels: [testing], relative_position: 10) }
+ let_it_be(:existing_issue2) { create(:labeled_issue, project: project, labels: [testing], relative_position: 50) }
+ let_it_be(:issue1) { create(:labeled_issue, project: project, labels: [development]) }
+
+ let(:mutation_class) { Mutations::Boards::Issues::IssueMoveList }
+ let(:mutation_name) { mutation_class.graphql_name }
+ let(:mutation_result_identifier) { mutation_name.camelize(:lower) }
+ let(:current_user) { user }
+ let(:params) { { board_id: board.to_global_id.to_s, project_path: project.full_path, iid: issue1.iid.to_s } }
+ let(:issue_move_params) do
+ {
+ from_list_id: list1.id,
+ to_list_id: list2.id
+ }
+ end
+
+ before_all do
+ group.add_maintainer(user)
+ end
+
+ shared_examples 'returns an error' do
+ it 'fails with error' do
+ message = "The resource that you are attempting to access does not exist or you don't have "\
+ "permission to perform this action"
+
+ post_graphql_mutation(mutation(params), current_user: current_user)
+
+ expect(graphql_errors).to include(a_hash_including('message' => message))
+ end
+ end
+
+ context 'when user has access to resources' do
+ context 'when repositioning an issue' do
+ let(:issue_move_params) { { move_after_id: existing_issue1.id, move_before_id: existing_issue2.id } }
+
+ it 'repositions an issue' do
+ post_graphql_mutation(mutation(params), current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ response_issue = json_response['data'][mutation_result_identifier]['issue']
+ expect(response_issue['iid']).to eq(issue1.iid.to_s)
+ expect(response_issue['relativePosition']).to be > existing_issue1.relative_position
+ expect(response_issue['relativePosition']).to be < existing_issue2.relative_position
+ end
+ end
+
+ context 'when moving an issue to a different list' do
+ let(:issue_move_params) { { from_list_id: list1.id, to_list_id: list2.id } }
+
+ it 'moves issue to a different list' do
+ post_graphql_mutation(mutation(params), current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ response_issue = json_response['data'][mutation_result_identifier]['issue']
+ expect(response_issue['iid']).to eq(issue1.iid.to_s)
+ expect(response_issue['labels']['edges'][0]['node']['title']).to eq(testing.title)
+ end
+ end
+ end
+
+ context 'when user has no access to resources' do
+ context 'the user is not allowed to update the issue' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'returns an error'
+ end
+
+ context 'when the user can not read board' do
+ let(:board) { create(:board, group: create(:group, :private)) }
+
+ it_behaves_like 'returns an error'
+ end
+ end
+
+ def mutation(additional_params = {})
+ graphql_mutation(mutation_name, issue_move_params.merge(additional_params),
+ <<-QL.strip_heredoc
+ clientMutationId
+ issue {
+ iid,
+ relativePosition
+ labels {
+ edges {
+ node{
+ title
+ }
+ }
+ }
+ }
+ errors
+ QL
+ )
+ end
+end
diff --git a/spec/services/git/process_ref_changes_service_spec.rb b/spec/services/git/process_ref_changes_service_spec.rb
index c2fb40a0ed0..d2d5ac5a7e8 100644
--- a/spec/services/git/process_ref_changes_service_spec.rb
+++ b/spec/services/git/process_ref_changes_service_spec.rb
@@ -190,18 +190,6 @@ RSpec.describe Git::ProcessRefChangesService do
subject.execute
end
-
- context 'refresh_only_existing_merge_requests_on_push disabled' do
- before do
- stub_feature_flags(refresh_only_existing_merge_requests_on_push: false)
- end
-
- it 'refreshes all merge requests' do
- expect(UpdateMergeRequestsWorker).to receive(:perform_async).exactly(3).times
-
- subject.execute
- end
- end
end
end
diff --git a/spec/workers/deployments/finished_worker_spec.rb b/spec/workers/deployments/finished_worker_spec.rb
index 9b4bd78c03a..e1ec2d89e0a 100644
--- a/spec/workers/deployments/finished_worker_spec.rb
+++ b/spec/workers/deployments/finished_worker_spec.rb
@@ -49,5 +49,29 @@ RSpec.describe Deployments::FinishedWorker do
expect(ProjectServiceWorker).not_to have_received(:perform_async)
end
+
+ it 'execute webhooks' do
+ deployment = create(:deployment)
+ project = deployment.project
+ web_hook = create(:project_hook, deployment_events: true, project: project)
+
+ expect_next_instance_of(WebHookService, web_hook, an_instance_of(Hash), "deployment_hooks") do |service|
+ expect(service).to receive(:async_execute)
+ end
+
+ worker.perform(deployment.id)
+ end
+
+ it 'does not execute webhooks if feature flag is disabled' do
+ stub_feature_flags(deployment_webhooks: false)
+
+ deployment = create(:deployment)
+ project = deployment.project
+ create(:project_hook, deployment_events: true, project: project)
+
+ expect(WebHookService).not_to receive(:new)
+
+ worker.perform(deployment.id)
+ end
end
end
diff --git a/yarn.lock b/yarn.lock
index 65496b9eff1..04d4312fcd9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -843,15 +843,15 @@
eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0"
-"@gitlab/svgs@1.157.0":
- version "1.157.0"
- resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.157.0.tgz#ada33c2b706836a2f5baa2c539f1348791d74859"
- integrity sha512-H07Rn4Cy2QW+wnadvuFBSIWrtn8l4hGFLn62f1fT0iYZy58zb/q5/FsShxk9cSKnZYNkXp8I4Nnk/4R7y1MEOw==
-
-"@gitlab/ui@18.1.0":
- version "18.1.0"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.1.0.tgz#36c1e292cae47d1580d2a3918fe5dd16893e2219"
- integrity sha512-oXKTJ07hMFYxXZiJOgbNzVCpz/ooz0rY7D3ISG9ocawGVFVjrwLj41wgNtOzYAnQntxUcgvxNeBt3X6SS/zeTg==
+"@gitlab/svgs@1.158.0":
+ version "1.158.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.158.0.tgz#300d416184a2b0e05f15a96547f726e1825b08a1"
+ integrity sha512-5OJl+7TsXN9PJhY6/uwi+mTwmDZa9n/6119rf77orQ/joFYUypaYhBmy/1TcKVPsy5Zs6KCxE1kmGsfoXc1TYA==
+
+"@gitlab/ui@18.3.0":
+ version "18.3.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.3.0.tgz#c582eca1a0a851823700dabc7f4456feef882d9a"
+ integrity sha512-H0I3ExZJIqDd9rFDzyZwUerS3ZHDxRf2wHmAzMzK9smq/kr8aL5Pvb2E0KPcgDsVhGQCt7coCBN5NI0p+kf8oQ==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"