diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-20 18:42:06 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-20 18:42:06 +0000 |
commit | 6e4e1050d9dba2b7b2523fdd1768823ab85feef4 (patch) | |
tree | 78be5963ec075d80116a932011d695dd33910b4e /app/assets/javascripts/releases | |
parent | 1ce776de4ae122aba3f349c02c17cebeaa8ecf07 (diff) | |
download | gitlab-ce-6e4e1050d9dba2b7b2523fdd1768823ab85feef4.tar.gz |
Add latest changes from gitlab-org/gitlab@13-3-stable-ee
Diffstat (limited to 'app/assets/javascripts/releases')
19 files changed, 422 insertions, 163 deletions
diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 01dd0638023..7b7c80a6269 100644 --- a/app/assets/javascripts/releases/components/app_edit.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -1,18 +1,17 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; -import { escape } from 'lodash'; 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'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; +import TagField from './tag_field.vue'; export default { - name: 'ReleaseEditApp', + name: 'ReleaseEditNewApp', components: { GlFormInput, GlFormGroup, @@ -20,9 +19,7 @@ export default { MarkdownField, AssetLinksForm, MilestoneCombobox, - }, - directives: { - autofocusonshow, + TagField, }, mixins: [glFeatureFlagsMixin()], computed: { @@ -39,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( @@ -55,23 +52,6 @@ export default { false, ); }, - tagName() { - return this.$store.state.detail.release.tagName; - }, - tagNameHintText() { - return sprintf( - __( - 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', - ), - { - linkStart: `<a href="${escape( - this.updateReleaseApiDocsPath, - )}" target="_blank" rel="noopener noreferrer">`, - linkEnd: '</a>', - }, - false, - ); - }, releaseTitle: { get() { return this.$store.state.detail.release.name; @@ -102,7 +82,10 @@ export default { showAssetLinksForm() { return this.glFeatures.releaseAssetLinkEditing; }, - isSaveChangesDisabled() { + saveButtonLabel() { + return this.isExistingRelease ? __('Save changes') : __('Create release'); + }, + isFormSubmissionDisabled() { return this.isUpdatingRelease || !this.isValid; }, milestoneComboboxExtraLinks() { @@ -118,53 +101,45 @@ 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', ]), + submitForm() { + if (!this.isFormSubmissionDisabled) { + this.saveRelease(); + } + }, }, }; </script> <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()"> - <gl-form-group> - <div class="row"> - <div class="col-md-6 col-lg-5 col-xl-4"> - <label for="git-ref">{{ __('Tag name') }}</label> - <gl-form-input - id="git-ref" - v-model="tagName" - type="text" - class="form-control" - aria-describedby="tag-name-help" - disabled - /> - </div> - </div> - <div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div> - </gl-form-group> + <form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm"> + <tag-field /> <gl-form-group> <label for="release-title">{{ __('Release title') }}</label> <gl-form-input id="release-title" ref="releaseTitleInput" v-model="releaseTitle" - v-autofocusonshow - autofocus type="text" class="form-control" /> </gl-form-group> - <gl-form-group class="w-50"> + <gl-form-group class="w-50" @keydown.enter.prevent.capture> <label>{{ __('Milestones') }}</label> <div class="d-flex flex-column col-md-6 col-sm-10 pl-0"> <milestone-combobox @@ -182,7 +157,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :add-spacing-classes="false" - class="prepend-top-10 append-bottom-10" + class="gl-mt-3 gl-mb-3" > <template #textarea> <textarea @@ -193,8 +168,6 @@ 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()" ></textarea> </template> </markdown-field> @@ -209,10 +182,11 @@ export default { category="primary" variant="success" type="submit" - :aria-label="__('Save changes')" - :disabled="isSaveChangesDisabled" - >{{ __('Save changes') }}</gl-button + :disabled="isFormSubmissionDisabled" + 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/components/app_new.vue b/app/assets/javascripts/releases/components/app_new.vue deleted file mode 100644 index 563f76b3281..00000000000 --- a/app/assets/javascripts/releases/components/app_new.vue +++ /dev/null @@ -1,9 +0,0 @@ -<script> -export default { - name: 'ReleaseNewApp', - components: {}, -}; -</script> -<template> - <div></div> -</template> diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index d0d1485d8e7..07fab840067 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -49,6 +49,12 @@ export default { this.removeAssetLink(linkId); this.ensureAtLeastOneLink(); }, + updateUrl(link, newUrl) { + this.updateAssetLinkUrl({ linkIdToUpdate: link.id, newUrl }); + }, + updateName(link, newName) { + this.updateAssetLinkName({ linkIdToUpdate: link.id, newName }); + }, hasDuplicateUrl(link) { return Boolean(this.getLinkErrors(link).isDuplicate); }, @@ -138,7 +144,9 @@ export default { type="text" class="form-control" :state="isUrlValid(link)" - @change="updateAssetLinkUrl({ linkIdToUpdate: link.id, newUrl: $event })" + @change="updateUrl(link, $event)" + @keydown.ctrl.enter="updateUrl(link, $event.target.value)" + @keydown.meta.enter="updateUrl(link, $event.target.value)" /> <template #invalid-feedback> <span v-if="hasEmptyUrl(link)" class="invalid-feedback d-inline"> @@ -175,7 +183,9 @@ export default { type="text" class="form-control" :state="isNameValid(link)" - @change="updateAssetLinkName({ linkIdToUpdate: link.id, newName: $event })" + @change="updateName(link, $event)" + @keydown.ctrl.enter="updateName(link, $event.target.value)" + @keydown.meta.enter="updateName(link, $event.target.value)" /> <template #invalid-feedback> <span v-if="hasEmptyName(link)" class="invalid-feedback d-inline"> diff --git a/app/assets/javascripts/releases/components/form_field_container.vue b/app/assets/javascripts/releases/components/form_field_container.vue new file mode 100644 index 00000000000..19e275315a0 --- /dev/null +++ b/app/assets/javascripts/releases/components/form_field_container.vue @@ -0,0 +1,12 @@ +<script> +export default { + name: 'FormFieldContainer', +}; +</script> +<template> + <div class="row"> + <div class="col-md-6 col-lg-5 col-xl-4 gl-display-flex gl-flex-direction-column"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index ab29ceb0ce6..9583f5737df 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -1,10 +1,10 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; +import { difference, get } from 'lodash'; import Icon from '~/vue_shared/components/icon.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ASSET_LINK_TYPE } from '../constants'; import { __, s__, sprintf } from '~/locale'; -import { difference, get } from 'lodash'; export default { name: 'ReleaseBlockAssets', @@ -138,7 +138,7 @@ export default { :aria-label="$options.externalLinkTooltipText" :title="$options.externalLinkTooltipText" data-testid="external-link-indicator" - class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-600" + class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400" /> </gl-link> </li> diff --git a/app/assets/javascripts/releases/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue index 94f2b1795f0..72c578068cd 100644 --- a/app/assets/javascripts/releases/components/release_block_author.vue +++ b/app/assets/javascripts/releases/components/release_block_author.vue @@ -1,6 +1,6 @@ <script> -import { __, sprintf } from '~/locale'; import { GlSprintf } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index b16ae400d6b..deff673cc17 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -7,9 +7,9 @@ import { GlTooltipDirective, GlSprintf, } from '@gitlab/ui'; +import { sum } from 'lodash'; import { __, n__, sprintf } from '~/locale'; import { MAX_MILESTONES_TO_DISPLAY } from '../constants'; -import { sum } from 'lodash'; export default { name: 'ReleaseBlockMilestoneInfo', diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue new file mode 100644 index 00000000000..ed8d6e62926 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_field.vue @@ -0,0 +1,20 @@ +<script> +import { mapGetters } from 'vuex'; +import TagFieldExisting from './tag_field_existing.vue'; +import TagFieldNew from './tag_field_new.vue'; + +export default { + components: { + TagFieldExisting, + TagFieldNew, + }, + computed: { + ...mapGetters('detail', ['isExistingRelease']), + }, +}; +</script> + +<template> + <tag-field-existing v-if="isExistingRelease" /> + <tag-field-new v-else /> +</template> diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue new file mode 100644 index 00000000000..b84e713df26 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_field_existing.vue @@ -0,0 +1,51 @@ +<script> +import { mapState } from 'vuex'; +import { uniqueId } from 'lodash'; +import { GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import FormFieldContainer from './form_field_container.vue'; + +export default { + name: 'TagFieldExisting', + components: { GlFormGroup, GlFormInput, GlSprintf, GlLink, FormFieldContainer }, + computed: { + ...mapState('detail', ['release', 'updateReleaseApiDocsPath']), + inputId() { + return uniqueId('tag-name-input-'); + }, + helpId() { + return uniqueId('tag-name-help-'); + }, + }, +}; +</script> +<template> + <gl-form-group :label="__('Tag name')" :label-for="inputId"> + <form-field-container> + <gl-form-input + :id="inputId" + :value="release.tagName" + type="text" + class="form-control" + :aria-describedby="helpId" + disabled + /> + </form-field-container> + <template #description> + <div :id="helpId" data-testid="tag-name-help"> + <gl-sprintf + :message=" + __( + 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="updateReleaseApiDocsPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + </template> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue new file mode 100644 index 00000000000..4779feae886 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -0,0 +1,100 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import FormFieldContainer from './form_field_container.vue'; + +export default { + name: 'TagFieldNew', + components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer }, + data() { + return { + // Keeps track of whether or not the user has interacted with + // the input field. This is used to avoid showing validation + // errors immediately when the page loads. + isInputDirty: false, + }; + }, + computed: { + ...mapState('detail', ['projectId', 'release', 'createFrom']), + ...mapGetters('detail', ['validationErrors']), + tagName: { + get() { + return this.release.tagName; + }, + set(tagName) { + this.updateReleaseTagName(tagName); + }, + }, + createFromModel: { + get() { + return this.createFrom; + }, + set(createFrom) { + this.updateCreateFrom(createFrom); + }, + }, + showTagNameValidationError() { + return this.isInputDirty && this.validationErrors.isTagNameEmpty; + }, + tagNameInputId() { + return uniqueId('tag-name-input-'); + }, + createFromSelectorId() { + return uniqueId('create-from-selector-'); + }, + }, + methods: { + ...mapActions('detail', ['updateReleaseTagName', 'updateCreateFrom']), + markInputAsDirty() { + this.isInputDirty = true; + }, + }, + translations: { + noRefSelected: __('No source selected'), + searchPlaceholder: __('Search branches, tags, and commits'), + dropdownHeader: __('Select source'), + }, +}; +</script> +<template> + <div> + <gl-form-group + :label="__('Tag name')" + :label-for="tagNameInputId" + data-testid="tag-name-field" + :state="!showTagNameValidationError" + :invalid-feedback="__('Tag name is required')" + > + <form-field-container> + <gl-form-input + :id="tagNameInputId" + v-model="tagName" + :state="!showTagNameValidationError" + type="text" + class="form-control" + @blur.once="markInputAsDirty" + /> + </form-field-container> + </gl-form-group> + <gl-form-group + :label="__('Create from')" + :label-for="createFromSelectorId" + data-testid="create-from-field" + > + <form-field-container> + <ref-selector + :id="createFromSelectorId" + v-model="createFromModel" + :project-id="projectId" + :translations="$options.translations" + /> + </form-field-container> + <template #description> + {{ __('Existing branch name, tag, or commit SHA') }} + </template> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js index 44530e4961a..c7385b3c57f 100644 --- a/app/assets/javascripts/releases/mount_edit.js +++ b/app/assets/javascripts/releases/mount_edit.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ReleaseEditApp from './components/app_edit.vue'; +import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; import createDetailModule from './stores/modules/detail'; @@ -18,6 +18,6 @@ export default () => { return new Vue({ el, store, - render: h => h(ReleaseEditApp), + render: h => h(ReleaseEditNewApp), }); }; diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index eb02c194c59..68003f6a346 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ReleaseNewApp from './components/app_new.vue'; +import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; import createDetailModule from './stores/modules/detail'; @@ -10,11 +10,14 @@ export default () => { modules: { detail: createDetailModule(el.dataset), }, + featureFlags: { + releaseShowPage: Boolean(gon.features?.releaseShowPage), + }, }); return new Vue({ el, store, - render: h => h(ReleaseNewApp), + render: h => h(ReleaseEditNewApp), }); }; diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 2026eeba880..5b682a0ab0f 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -1,74 +1,116 @@ import * as types from './mutation_types'; import api from '~/api'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as 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 @@ -86,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 84dc2fca4be..809ed075c16 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/getters.js +++ b/app/assets/javascripts/releases/stores/modules/detail/getters.js @@ -2,6 +2,14 @@ import { isEmpty } from 'lodash'; import { hasContent } from '~/lib/utils/text_utility'; /** + * @returns {Boolean} `true` if the app is editing an existing release. + * `false` if the app is creating a new release. + */ +export const isExistingRelease = state => { + return Boolean(state.tagName); +}; + +/** * @param {Object} link The link to test * @returns {Boolean} `true` if the release link is empty, i.e. it has * empty (or whitespace-only) values for both `url` and `name`. @@ -39,6 +47,10 @@ export const validationErrors = state => { return errors; } + if (!state.release.tagName?.trim?.().length) { + errors.isTagNameEmpty = true; + } + // Each key of this object is a URL, and the value is an // array of Release link objects that share this URL. // This is used for detecting duplicate URLs. @@ -88,5 +100,6 @@ export const validationErrors = state => { /** Returns whether or not the release object is valid */ export const isValid = (_state, getters) => { - return Object.values(getters.validationErrors.assets.links).every(isEmpty); + const errors = getters.validationErrors; + return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty; }; 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 7b694120126..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,14 +1,18 @@ +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'; +export const UPDATE_RELEASE_TAG_NAME = 'UPDATE_RELEASE_TAG_NAME'; +export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM'; 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 ca544151323..750f496665d 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import { uniqueId, cloneDeep } from 'lodash'; +import * as types from './mutation_types'; import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants'; const findReleaseLink = (release, id) => { @@ -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; }, @@ -22,6 +34,12 @@ export default { state.release = undefined; }, + [types.UPDATE_RELEASE_TAG_NAME](state, tagName) { + state.release.tagName = tagName; + }, + [types.UPDATE_CREATE_FROM](state, createFrom) { + state.createFrom = createFrom; + }, [types.UPDATE_RELEASE_TITLE](state, title) { state.release.name = title; }, @@ -33,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/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index 966c1c00ef5..a46e750df53 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -6,9 +6,9 @@ export default ({ releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, + releasesPagePath, tagName = null, - releasesPagePath = null, defaultBranch = null, }) => ({ projectId, @@ -18,10 +18,16 @@ export default ({ releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, + releasesPagePath, + /** + * The name of the tag associated with the release, provided by the backend. + * When creating a new release, this value is null. + */ tagName, - releasesPagePath, + defaultBranch, + createFrom: defaultBranch, /** The Release object */ release: null, diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js index 06d13890a9d..90fba319e9f 100644 --- a/app/assets/javascripts/releases/stores/modules/list/actions.js +++ b/app/assets/javascripts/releases/stores/modules/list/actions.js @@ -1,5 +1,5 @@ import * as types from './mutation_types'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import api from '~/api'; import { @@ -43,6 +43,3 @@ export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); createFlash(__('An error occurred while fetching the releases. Please try again.')); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js new file mode 100644 index 00000000000..842a423b142 --- /dev/null +++ b/app/assets/javascripts/releases/util.js @@ -0,0 +1,41 @@ +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 name = release.name?.trim().length > 0 ? release.name.trim() : null; + + const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; + + return convertObjectPropsToSnakeCase( + { + name, + tagName: release.tagName, + ref: createFrom, + 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; +}; |