Commit 35f17fe0 authored by Nathan Friend's avatar Nathan Friend Committed by Andrew Fontaine

Update Edit Release page to support creation

This commit updates the Edit Release page to additionally support the
creation of new releases.
parent 611b819d
......@@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
......@@ -22,9 +21,6 @@ export default {
MilestoneCombobox,
TagField,
},
directives: {
autofocusonshow,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', [
......@@ -40,9 +36,9 @@ export default {
'manageMilestonesPath',
'projectId',
]),
...mapGetters('detail', ['isValid']),
...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() {
return !this.isFetchingRelease && !this.fetchError;
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
subtitleText() {
return sprintf(
......@@ -86,6 +82,9 @@ export default {
showAssetLinksForm() {
return this.glFeatures.releaseAssetLinkEditing;
},
saveButtonLabel() {
return this.isExistingRelease ? __('Save changes') : __('Create release');
},
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
......@@ -102,13 +101,17 @@ export default {
];
},
},
created() {
this.fetchRelease();
mounted() {
// eslint-disable-next-line promise/catch-or-return
this.initializeRelease().then(() => {
// Focus the first non-disabled input element
this.$el.querySelector('input:enabled').focus();
});
},
methods: {
...mapActions('detail', [
'fetchRelease',
'updateRelease',
'initializeRelease',
'saveRelease',
'updateReleaseTitle',
'updateReleaseNotes',
'updateReleaseMilestones',
......@@ -119,7 +122,7 @@ export default {
<template>
<div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
<form v-if="showForm" @submit.prevent="updateRelease()">
<form v-if="showForm" @submit.prevent="saveRelease()">
<tag-field />
<gl-form-group>
<label for="release-title">{{ __('Release title') }}</label>
......@@ -127,8 +130,6 @@ export default {
id="release-title"
ref="releaseTitleInput"
v-model="releaseTitle"
v-autofocusonshow
autofocus
type="text"
class="form-control"
/>
......@@ -162,8 +163,8 @@ export default {
data-supports-quick-actions="false"
:aria-label="__('Release notes')"
:placeholder="__('Write your release notes or drag your files here…')"
@keydown.meta.enter="updateRelease()"
@keydown.ctrl.enter="updateRelease()"
@keydown.meta.enter="saveRelease()"
@keydown.ctrl.enter="saveRelease()"
></textarea>
</template>
</markdown-field>
......@@ -178,10 +179,11 @@ export default {
category="primary"
variant="success"
type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
>{{ __('Save changes') }}</gl-button
data-testid="submit-button"
>
{{ saveButtonLabel }}
</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
......
......@@ -3,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 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);
};
export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath);
};
const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
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);
};
......@@ -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);
};
/**
......
export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE';
export const REQUEST_RELEASE = 'REQUEST_RELEASE';
export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS';
export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
......@@ -8,9 +10,9 @@ export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR';
export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE';
export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS';
export const RECEIVE_SAVE_RELEASE_ERROR = 'RECEIVE_SAVE_RELEASE_ERROR';
export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK';
export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL';
......
......@@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => {
};
export default {
[types.INITIALIZE_EMPTY_RELEASE](state) {
state.release = {
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
};
},
[types.REQUEST_RELEASE](state) {
state.isFetchingRelease = true;
},
......@@ -39,14 +51,14 @@ export default {
state.release.milestones = milestones;
},
[types.REQUEST_UPDATE_RELEASE](state) {
[types.REQUEST_SAVE_RELEASE](state) {
state.isUpdatingRelease = true;
},
[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) {
[types.RECEIVE_SAVE_RELEASE_SUCCESS](state) {
state.updateError = undefined;
state.isUpdatingRelease = false;
},
[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) {
[types.RECEIVE_SAVE_RELEASE_ERROR](state, error) {
state.updateError = error;
state.isUpdatingRelease = false;
},
......
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;
};
......@@ -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 ""
......
......@@ -27,8 +27,8 @@ describe('Release edit/new component', () => {
};
actions = {
fetchRelease: jest.fn(),
updateRelease: jest.fn(),
initializeRelease: jest.fn(),
saveRelease: jest.fn(),
addEmptyAssetLink: jest.fn(),
};
......@@ -64,6 +64,8 @@ describe('Release edit/new component', () => {
glFeatures: featureFlags,
},
});
wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus'));
};
beforeEach(() => {
......@@ -87,8 +89,18 @@ describe('Release edit/new component', () => {
factory();
});
it('calls fetchRelease when the component is created', () => {
expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
it('calls initializeRelease when the component is created', () => {
expect(actions.initializeRelease).toHaveBeenCalledTimes(1);
});
it('focuses the first non-disabled input element once the page is shown', () => {
const firstEnabledInput = wrapper.element.querySelector('input:enabled');
const allInputs = wrapper.element.querySelectorAll('input');
allInputs.forEach(input => {
const expectedFocusCalls = input === firstEnabledInput ? 1 : 0;
expect(input.focus).toHaveBeenCalledTimes(expectedFocusCalls);
});
});
it('renders the description text at the top of the page', () => {
......@@ -109,9 +121,9 @@ describe('Release edit/new component', () => {
expect(findSubmitButton().attributes('type')).toBe('submit');
});
it('calls updateRelease when the form is submitted', () => {
it('calls saveRelease when the form is submitted', () => {
wrapper.find('form').trigger('submit');
expect(actions.updateRelease).toHaveBeenCalledTimes(1);
expect(actions.saveRelease).toHaveBeenCalledTimes(1);
});
});
......@@ -143,6 +155,34 @@ describe('Release edit/new component', () => {
});
});
describe('when creating a new release', () => {
beforeEach(() => {
factory({
store: {
modules: {
detail: {
getters: {
isExistingRelease: () => false,
},
},
},
},
});
});
it('renders the submit button with the text "Create release"', () => {
expect(findSubmitButton().text()).toBe('Create release');
});
});
describe('when editing an existing release', () => {
beforeEach(factory);
it('renders the submit button with the text "Save changes"', () => {
expect(findSubmitButton().text()).toBe('Save changes');
});
});
describe('asset links form', () => {
const findAssetLinksForm = () => wrapper.find(AssetLinksForm);
......
......@@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => {
let store;
let wrapper;
const createComponent = ({ originalRelease }) => {
const createComponent = ({ tagName }) => {
store = createStore({
modules: {
detail: createDetailModule({}),
},
});
store.state.detail.originalRelease = originalRelease;
store.state.detail.tagName = tagName;
wrapper = shallowMount(TagField, { store });
};
......@@ -31,8 +31,7 @@ describe('releases/components/tag_field', () => {
describe('when an existing release is being edited', () => {
beforeEach(() => {
const originalRelease = { name: 'Version 1.0' };
createComponent({ originalRelease });
createComponent({ tagName: 'v1.0' });
});
it('renders the TagFieldExisting component', () => {
......@@ -46,7 +45,7 @@ describe('releases/components/tag_field', () => {
describe('when a new release is being created', () => {
beforeEach(() => {
createComponent({ originalRelease: null });
createComponent({ tagName: null });
});
it('renders the TagFieldNew component', () => {
......
......@@ -3,13 +3,13 @@ import * as getters from '~/releases/stores/modules/detail/getters';
describe('Release detail getters', () => {
describe('isExistingRelease', () => {
it('returns true if the release is an existing release that already exists in the database', () => {
const state = { originalRelease: { name: 'The first release' } };
const state = { tagName: 'test-tag-name' };
expect(getters.isExistingRelease(state)).toBe(true);
});
it('returns false if the release is a new release that has not yet been saved to the database', () => {
const state = { originalRelease: null };
const state = { tagName: null };
expect(getters.isExistingRelease(state)).toBe(false);
});
......
......@@ -21,6 +21,22 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease);
});
describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => {
it('set state.release to an empty release object', () => {
mutations[types.INITIALIZE_EMPTY_RELEASE](state);
expect(state.release).toEqual({
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
});
});
});
describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state);
......@@ -96,17 +112,17 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.REQUEST_UPDATE_RELEASE}`, () => {
describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_UPDATE_RELEASE](state);
mutations[types.REQUEST_SAVE_RELEASE](state);
expect(state.isUpdatingRelease).toBe(true);
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release);
expect(state.updateError).toBeUndefined();
......@@ -114,10 +130,10 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error);
mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error);
expect(state.isUpdatingRelease).toBe(false);
......
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);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment