Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
bf593ae68b
commit
7073275386
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -3,125 +3,48 @@ 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';
|
||||
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
|
||||
|
||||
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'));
|
||||
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 receiveUpdateReleaseError = ({ commit }, error) => {
|
||||
commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error);
|
||||
createFlash(s__('Release|Something went wrong while saving the release details'));
|
||||
};
|
||||
|
||||
export const updateRelease = ({ dispatch, state, getters }) => {
|
||||
dispatch('requestUpdateRelease');
|
||||
|
||||
const { release } = state;
|
||||
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
|
||||
|
||||
const updatedRelease = convertObjectPropsToSnakeCase(
|
||||
{
|
||||
name: release.name,
|
||||
description: release.description,
|
||||
milestones,
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
return (
|
||||
api
|
||||
.updateRelease(state.projectId, state.tagName, updatedRelease)
|
||||
|
||||
/**
|
||||
* Currently, we delete all existing links and then
|
||||
* recreate new ones on each edit. This is because the
|
||||
* REST API doesn't support bulk updating of Release links,
|
||||
* and updating individual links can lead to validation
|
||||
* race conditions (in particular, the "URLs must be unique")
|
||||
* constraint.
|
||||
*
|
||||
* This isn't ideal since this is no longer an atomic
|
||||
* operation - parts of it can fail while others succeed,
|
||||
* leaving the Release in an inconsistent state.
|
||||
*
|
||||
* This logic should be refactored to use GraphQL once
|
||||
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702
|
||||
* is closed.
|
||||
*/
|
||||
|
||||
.then(() => {
|
||||
// Delete all links currently associated with this Release
|
||||
return Promise.all(
|
||||
getters.releaseLinksToDelete.map(l =>
|
||||
api.deleteReleaseLink(state.projectId, 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 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.then(() => dispatch('receiveUpdateReleaseSuccess'))
|
||||
.catch(error => {
|
||||
dispatch('receiveUpdateReleaseError', error);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const navigateToReleasesPage = ({ state }) => {
|
||||
redirectTo(state.releasesPagePath);
|
||||
};
|
||||
|
||||
export const addEmptyAssetLink = ({ commit }) => {
|
||||
commit(types.ADD_EMPTY_ASSET_LINK);
|
||||
};
|
||||
|
@ -141,3 +64,95 @@ export const updateAssetLinkType = ({ commit }, { 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);
|
||||
|
||||
dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
|
||||
};
|
||||
|
||||
export const createRelease = ({ commit, dispatch, state, getters }) => {
|
||||
const apiJson = releaseToApiJson(
|
||||
{
|
||||
...state.release,
|
||||
assets: {
|
||||
links: getters.releaseLinksToCreate,
|
||||
},
|
||||
},
|
||||
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, apiJson)
|
||||
|
||||
/**
|
||||
* Currently, we delete all existing links and then
|
||||
* recreate new ones on each edit. This is because the
|
||||
* REST API doesn't support bulk updating of Release links,
|
||||
* and updating individual links can lead to validation
|
||||
* race conditions (in particular, the "URLs must be unique")
|
||||
* constraint.
|
||||
*
|
||||
* This isn't ideal since this is no longer an atomic
|
||||
* operation - parts of it can fail while others succeed,
|
||||
* leaving the Release in an inconsistent state.
|
||||
*
|
||||
* This logic should be refactored to use GraphQL once
|
||||
* 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;
|
||||
|
||||
// Delete all links currently associated with this Release
|
||||
return Promise.all(
|
||||
getters.releaseLinksToDelete.map(l =>
|
||||
api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
|
||||
),
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// Create a new link for each link in the form
|
||||
return Promise.all(
|
||||
apiJson.assets.links.map(l =>
|
||||
api.createReleaseLink(state.projectId, state.release.tagName, l),
|
||||
),
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
|
||||
})
|
||||
.catch(error => {
|
||||
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
|
||||
createFlash(s__('Release|Something went wrong while saving the release details'));
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -1,26 +1,79 @@
|
|||
/**
|
||||
* 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 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()}`;
|
||||
|
||||
const nonErbMarkupRegex = new RegExp(`^((<(?!%).+>){1}(${template})+(</.+>){1})$`, 'gm');
|
||||
const embeddedRubyBlockRegex = new RegExp(`(^<%(${template})+%>$)`, 'gm');
|
||||
const embeddedRubyInlineRegex = new RegExp(`(^.*[<|<]%(${template})+$)`, 'gm');
|
||||
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(`(^.*[<|<]%(${reHelpers.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 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 mark = (source, groups) => {
|
||||
let text = source;
|
||||
let id = 0;
|
||||
const hash = {};
|
||||
|
||||
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 unmark = (text, hash) => {
|
||||
let source = text;
|
||||
|
||||
Object.entries(hash).forEach(([key, value]) => {
|
||||
const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`;
|
||||
source = source.replace(key, newVal);
|
||||
});
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
const unwrap = source => {
|
||||
let text = source;
|
||||
const matches = text.match(templatedRegex);
|
||||
const matches = text.match(reTemplated);
|
||||
|
||||
if (matches) {
|
||||
matches.forEach(match => {
|
||||
const initial = match.replace(`${prefix}`, '').replace(`${postfix}`, '');
|
||||
const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, '');
|
||||
text = text.replace(match, initial);
|
||||
});
|
||||
}
|
||||
|
@ -28,35 +81,9 @@ const unwrap = source => {
|
|||
return text;
|
||||
};
|
||||
|
||||
const flag = (source, patterns) => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { text, hash };
|
||||
};
|
||||
|
||||
const wrap = source => {
|
||||
const { text, hash } = flag(unwrap(source), orderedPatterns);
|
||||
|
||||
let wrappedSource = text;
|
||||
Object.entries(hash).forEach(([key, value]) => {
|
||||
wrappedSource = wrappedSource.replace(key, `${prefix}${value}${postfix}`);
|
||||
});
|
||||
|
||||
return wrappedSource;
|
||||
const { text, hash } = mark(unwrap(source), patternGroups);
|
||||
return unmark(text, hash);
|
||||
};
|
||||
|
||||
export default { wrap, unwrap };
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: GraphQL mutation to move issue within board lists
|
||||
merge_request: 38309
|
||||
author:
|
||||
type: added
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix multiline comment rendering
|
||||
merge_request: 38721
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove Internet Explorer 11 from babel transpilation
|
||||
merge_request: 36840
|
||||
author:
|
||||
type: removed
|
|
@ -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).
|
||||
|
|
|
@ -6667,6 +6667,71 @@ type IssueEdge {
|
|||
node: Issue
|
||||
}
|
||||
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
@ -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
|
||||
|
|
|
@ -18428,6 +18428,176 @@
|
|||
"enumValues": null,
|
||||
"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",
|
||||
|
@ -26040,6 +26210,33 @@
|
|||
"isDeprecated": false,
|
||||
"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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
---
|
||||
redirect_to: 'documentation/styleguide.md'
|
||||
---
|
||||
|
||||
This document was moved to [another location](documentation/styleguide.md).
|
||||
|
|
|
@ -1 +1,5 @@
|
|||
---
|
||||
redirect_to: 'feature_flags/index.md'
|
||||
---
|
||||
|
||||
This document was moved to [another location](feature_flags/index.md).
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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('receiveReleaseSuccess', () => {
|
||||
it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () =>
|
||||
testAction(actions.receiveReleaseSuccess, release, state, [
|
||||
{ type: types.RECEIVE_RELEASE_SUCCESS, payload: release },
|
||||
]));
|
||||
});
|
||||
|
||||
describe('receiveReleaseError', () => {
|
||||
it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () =>
|
||||
testAction(actions.receiveReleaseError, error, state, [
|
||||
{ type: types.RECEIVE_RELEASE_ERROR, payload: error },
|
||||
]));
|
||||
|
||||
it('shows a flash with an error message', () => {
|
||||
actions.receiveReleaseError({ commit: jest.fn() }, error);
|
||||
|
||||
expect(createFlash).toHaveBeenCalledTimes(1);
|
||||
expect(createFlash).toHaveBeenCalledWith(
|
||||
'Something went wrong while getting the release details',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRelease', () => {
|
||||
let getReleaseUrl;
|
||||
|
||||
describe('when creating a new release', () => {
|
||||
beforeEach(() => {
|
||||
state.projectId = '18';
|
||||
state.tagName = 'v1.3';
|
||||
getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
|
||||
setupState({ isExistingRelease: false });
|
||||
});
|
||||
|
||||
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 }),
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it(`dispatches requestRelease and receiveReleaseError with an error object`, () => {
|
||||
mock.onGet(getReleaseUrl).replyOnce(500);
|
||||
|
||||
return testAction(
|
||||
actions.fetchRelease,
|
||||
undefined,
|
||||
state,
|
||||
[],
|
||||
[{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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('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('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.updateAssetLinkUrl, params, state, [
|
||||
{ type: types.UPDATE_ASSET_LINK_URL, payload: params },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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.updateAssetLinkName, params, state, [
|
||||
{ type: types.UPDATE_ASSET_LINK_NAME, 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,
|
||||
};
|
||||
|
||||
return testAction(actions.updateAssetLinkType, params, state, [
|
||||
{ type: types.UPDATE_ASSET_LINK_TYPE, 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('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('requestUpdateRelease', () => {
|
||||
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
|
||||
testAction(actions.requestUpdateRelease, undefined, state, [
|
||||
{ type: types.REQUEST_UPDATE_RELEASE },
|
||||
]));
|
||||
});
|
||||
|
||||
describe('receiveUpdateReleaseSuccess', () => {
|
||||
it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () =>
|
||||
testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
|
||||
{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
|
||||
]));
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState });
|
||||
|
||||
expect(redirectTo).toHaveBeenCalledTimes(1);
|
||||
expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self);
|
||||
});
|
||||
|
||||
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 },
|
||||
]));
|
||||
|
||||
it('shows a flash with an error message', () => {
|
||||
actions.receiveUpdateReleaseError({ commit: jest.fn() }, error);
|
||||
|
||||
expect(createFlash).toHaveBeenCalledTimes(1);
|
||||
expect(createFlash).toHaveBeenCalledWith(
|
||||
'Something went wrong while saving the release details',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRelease', () => {
|
||||
let getters;
|
||||
let dispatch;
|
||||
let callOrder;
|
||||
|
||||
beforeEach(() => {
|
||||
state.release = convertObjectPropsToCamelCase(release);
|
||||
state.projectId = '18';
|
||||
state.tagName = state.release.tagName;
|
||||
|
||||
getters = {
|
||||
releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
|
||||
releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
|
||||
};
|
||||
|
||||
dispatch = jest.fn();
|
||||
|
||||
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 requestUpdateRelease and receiveUpdateReleaseSuccess', () => {
|
||||
return actions.updateRelease({ dispatch, state, getters }).then(() => {
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
['requestUpdateRelease'],
|
||||
['receiveUpdateReleaseSuccess'],
|
||||
describe('initializeRelease', () => {
|
||||
it(`commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => {
|
||||
testAction(actions.initializeRelease, undefined, state, [
|
||||
{ type: types.INITIALIZE_EMPTY_RELEASE },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
|
||||
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
|
||||
describe('saveRelease', () => {
|
||||
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => {
|
||||
testAction(
|
||||
actions.saveRelease,
|
||||
undefined,
|
||||
state,
|
||||
[{ type: types.REQUEST_SAVE_RELEASE }],
|
||||
[{ type: 'createRelease' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return actions.updateRelease({ dispatch, state, getters }).then(() => {
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
['requestUpdateRelease'],
|
||||
['receiveUpdateReleaseError', error],
|
||||
]);
|
||||
describe('when editing an existing release', () => {
|
||||
beforeEach(setupState);
|
||||
|
||||
describe('initializeRelease', () => {
|
||||
it('dispatches "fetchRelease"', () => {
|
||||
testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]);
|
||||
});
|
||||
});
|
||||
|
||||
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('saveRelease', () => {
|
||||
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => {
|
||||
testAction(
|
||||
actions.saveRelease,
|
||||
undefined,
|
||||
state,
|
||||
[{ type: types.REQUEST_SAVE_RELEASE }],
|
||||
[{ type: 'updateRelease' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
],
|
||||
]);
|
||||
describe('actions that behave the same whether creating a new release or editing an existing release', () => {
|
||||
beforeEach(setupState);
|
||||
|
||||
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToDelete.length);
|
||||
getters.releaseLinksToDelete.forEach(link => {
|
||||
expect(api.deleteReleaseLink).toHaveBeenCalledWith(
|
||||
state.projectId,
|
||||
state.tagName,
|
||||
link.id,
|
||||
);
|
||||
describe('fetchRelease', () => {
|
||||
let getReleaseUrl;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
expect(api.createReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToCreate.length);
|
||||
getters.releaseLinksToCreate.forEach(link => {
|
||||
expect(api.createReleaseLink).toHaveBeenCalledWith(state.projectId, state.tagName, link);
|
||||
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 }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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('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('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('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('addEmptyAssetLink', () => {
|
||||
it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => {
|
||||
return testAction(actions.addEmptyAssetLink, undefined, state, [
|
||||
{ type: types.ADD_EMPTY_ASSET_LINK },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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.updateAssetLinkUrl, params, state, [
|
||||
{ type: types.UPDATE_ASSET_LINK_URL, payload: params },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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.updateAssetLinkName, params, state, [
|
||||
{ type: types.UPDATE_ASSET_LINK_NAME, 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,
|
||||
};
|
||||
|
||||
return testAction(actions.updateAssetLinkType, params, state, [
|
||||
{ type: types.UPDATE_ASSET_LINK_TYPE, 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('receiveSaveReleaseSuccess', () => {
|
||||
it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () =>
|
||||
testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
|
||||
{ type: types.RECEIVE_SAVE_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 release's dedicated page", () => {
|
||||
expect(redirectTo).toHaveBeenCalledTimes(1);
|
||||
expect(redirectTo).toHaveBeenCalledWith(release._links.self);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the releaseShowPage feature flag is disabled', () => {
|
||||
beforeEach(() => {
|
||||
const rootState = { featureFlags: { releaseShowPage: false } };
|
||||
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
|
||||
});
|
||||
|
||||
it("redirects to the project's main Releases page", () => {
|
||||
expect(redirectTo).toHaveBeenCalledTimes(1);
|
||||
expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRelease', () => {
|
||||
let createReleaseUrl;
|
||||
let releaseLinksToCreate;
|
||||
|
||||
beforeEach(() => {
|
||||
const camelCasedRelease = convertObjectPropsToCamelCase(release);
|
||||
|
||||
releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1);
|
||||
|
||||
setupState({
|
||||
release: camelCasedRelease,
|
||||
releaseLinksToCreate,
|
||||
});
|
||||
|
||||
createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`;
|
||||
});
|
||||
|
||||
describe('when the network request to the Release API is successful', () => {
|
||||
beforeEach(() => {
|
||||
const expectedRelease = releaseToApiJson({
|
||||
...state.release,
|
||||
assets: {
|
||||
links: releaseLinksToCreate,
|
||||
},
|
||||
});
|
||||
|
||||
mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release);
|
||||
});
|
||||
|
||||
it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => {
|
||||
return testAction(
|
||||
actions.createRelease,
|
||||
undefined,
|
||||
state,
|
||||
[],
|
||||
[
|
||||
{
|
||||
type: 'receiveSaveReleaseSuccess',
|
||||
payload: apiJsonToRelease(release, { deep: true }),
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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]]);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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`
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
16
yarn.lock
16
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/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.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/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"
|
||||
|
|
Loading…
Reference in New Issue