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