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