Commit d6dc0201 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'nfriend-reuse-edit-release-for-new' into 'master'

Reuse the "Edit Release" form for release creation

See merge request gitlab-org/gitlab!36466
parents 9abe46ab 35f17fe0
......@@ -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', () => {
......
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;
beforeEach(() => {
state = createState({
const setupState = (updates = {}) => {
const getters = {
isExistingRelease: true,
};
state = {
...createState({
projectId: '18',
tagName: 'v1.3',
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(() => {
release = cloneDeep(originalRelease);
mock = new MockAdapter(axios);
gon.api_version = 'v4';
......@@ -45,73 +58,109 @@ 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('receiveReleaseError', () => {
it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () =>
testAction(actions.receiveReleaseError, error, state, [
{ type: types.RECEIVE_RELEASE_ERROR, payload: error },
]));
describe('saveRelease', () => {
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => {
testAction(
actions.saveRelease,
undefined,
state,
[{ type: types.REQUEST_SAVE_RELEASE }],
[{ type: 'createRelease' }],
);
});
});
});
it('shows a flash with an error message', () => {
actions.receiveReleaseError({ commit: jest.fn() }, error);
describe('when editing an existing release', () => {
beforeEach(setupState);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while getting the release details',
describe('initializeRelease', () => {
it('dispatches "fetchRelease"', () => {
testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]);
});
});
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('actions that behave the same whether creating a new release or editing an existing release', () => {
beforeEach(setupState);
describe('fetchRelease', () => {
let getReleaseUrl;
beforeEach(() => {
state.projectId = '18';
state.tagName = 'v1.3';
getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
});
it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => {
mock.onGet(getReleaseUrl).replyOnce(200, release);
describe('when the network request to the Release API is successful', () => {
beforeEach(() => {
mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release);
});
return testAction(
actions.fetchRelease,
undefined,
state,
[],
[
{ type: 'requestRelease' },
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => {
return testAction(actions.fetchRelease, undefined, state, [
{
type: 'receiveReleaseSuccess',
payload: convertObjectPropsToCamelCase(release, { deep: true }),
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);
});
return testAction(
actions.fetchRelease,
undefined,
state,
[],
[{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }],
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => {
return testAction(actions.fetchRelease, undefined, state, [
{
type: types.REQUEST_RELEASE,
},
{
type: types.RECEIVE_RELEASE_ERROR,
payload: expect.any(Error),
},
]);
});
it(`shows a flash message`, () => {
return actions.fetchRelease({ commit: jest.fn(), state }).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while getting the release details',
);
});
});
});
});
describe('updateReleaseTagName', () => {
it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => {
......@@ -149,6 +198,23 @@ describe('Release detail actions', () => {
});
});
describe('updateReleaseMilestones', () => {
it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
const newReleaseMilestones = ['v0.0', 'v0.1'];
return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
{ type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
]);
});
});
describe('addEmptyAssetLink', () => {
it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => {
return testAction(actions.addEmptyAssetLink, undefined, state, [
{ type: types.ADD_EMPTY_ASSET_LINK },
]);
});
});
describe('updateAssetLinkUrl', () => {
it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => {
const params = {
......@@ -197,85 +263,133 @@ describe('Release detail actions', () => {
});
});
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('receiveSaveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
{ type: types.RECEIVE_SAVE_RELEASE_SUCCESS },
]));
describe('when the releaseShowPage feature flag is enabled', () => {
beforeEach(() => {
const rootState = { featureFlags: { releaseShowPage: true } };
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
});
it("redirects to the release's dedicated page", () => {
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(release._links.self);
});
});
describe('requestUpdateRelease', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, state, [
{ type: types.REQUEST_UPDATE_RELEASE },
]));
describe('when the releaseShowPage feature flag is disabled', () => {
beforeEach(() => {
const rootState = { featureFlags: { releaseShowPage: false } };
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release);
});
describe('receiveUpdateReleaseSuccess', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [
{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
]));
it("redirects to the project's main Releases page", () => {
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath);
});
});
});
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',
},
},
describe('createRelease', () => {
let createReleaseUrl;
let releaseLinksToCreate;
beforeEach(() => {
const camelCasedRelease = convertObjectPropsToCamelCase(release);
releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1);
setupState({
release: camelCasedRelease,
releaseLinksToCreate,
});
actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState });
createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`;
});
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self);
describe('when the network request to the Release API is successful', () => {
beforeEach(() => {
const expectedRelease = releaseToApiJson({
...state.release,
assets: {
links: releaseLinksToCreate,
},
});
describe('when the releaseShowPage feature flag is disabled', () => {});
mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release);
});
describe('receiveUpdateReleaseError', () => {
it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () =>
testAction(actions.receiveUpdateReleaseError, error, state, [
{ type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error },
]));
it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => {
return testAction(
actions.createRelease,
undefined,
state,
[],
[
{
type: 'receiveSaveReleaseSuccess',
payload: apiJsonToRelease(release, { deep: true }),
},
],
);
});
});
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
});
it('shows a flash with an error message', () => {
actions.receiveUpdateReleaseError({ commit: jest.fn() }, 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 saving the release details',
'Something went wrong while creating a new release',
);
});
});
});
});
describe('updateRelease', () => {
let getters;
let dispatch;
let commit;
let callOrder;
beforeEach(() => {
state.release = convertObjectPropsToCamelCase(release);
state.projectId = '18';
state.tagName = state.release.tagName;
getters = {
releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
};
setupState({
release: convertObjectPropsToCamelCase(release),
...getters,
});
dispatch = jest.fn();
commit = jest.fn();
callOrder = [];
jest.spyOn(api, 'updateRelease').mockImplementation(() => {
callOrder.push('updateRelease');
return Promise.resolve();
return Promise.resolve({ data: release });
});
jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
callOrder.push('deleteReleaseLink');
......@@ -287,22 +401,11 @@ describe('Release detail actions', () => {
});
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseSuccess', () => {
return actions.updateRelease({ dispatch, state, getters }).then(() => {
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([
['requestUpdateRelease'],
['receiveUpdateReleaseSuccess'],
]);
});
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
return actions.updateRelease({ dispatch, state, getters }).then(() => {
expect(dispatch.mock.calls).toEqual([
['requestUpdateRelease'],
['receiveUpdateReleaseError', error],
['receiveSaveReleaseSuccess', apiJsonToRelease(release)],
]);
});
});
......@@ -321,15 +424,18 @@ describe('Release detail actions', () => {
[
state.projectId,
state.tagName,
{
name: state.release.name,
description: state.release.description,
milestones: state.release.milestones.map(milestone => milestone.title),
releaseToApiJson({
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
}),
],
]);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToDelete.length);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToDelete.length,
);
getters.releaseLinksToDelete.forEach(link => {
expect(api.deleteReleaseLink).toHaveBeenCalledWith(
state.projectId,
......@@ -338,9 +444,38 @@ describe('Release detail actions', () => {
);
});
expect(api.createReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToCreate.length);
expect(api.createReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToCreate.length,
);
getters.releaseLinksToCreate.forEach(link => {
expect(api.createReleaseLink).toHaveBeenCalledWith(state.projectId, state.tagName, link);
expect(api.createReleaseLink).toHaveBeenCalledWith(
state.projectId,
state.tagName,
link,
);
});
});
});
});
describe('when the network request to the Release API fails', () => {
beforeEach(() => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
});
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]);
});
});
it('shows a flash message', () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while saving the release details',
);
});
});
});
});
......
......@@ -3,13 +3,13 @@ import * as getters from '~/releases/stores/modules/detail/getters';
describe('Release detail getters', () => {
describe('isExistingRelease', () => {
it('returns true if the release is an existing release that already exists in the database', () => {
const state = { originalRelease: { name: 'The first release' } };
const state = { tagName: 'test-tag-name' };
expect(getters.isExistingRelease(state)).toBe(true);
});
it('returns false if the release is a new release that has not yet been saved to the database', () => {
const state = { originalRelease: null };
const state = { tagName: null };
expect(getters.isExistingRelease(state)).toBe(false);
});
......
......@@ -21,6 +21,22 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease);
});
describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => {
it('set state.release to an empty release object', () => {
mutations[types.INITIALIZE_EMPTY_RELEASE](state);
expect(state.release).toEqual({
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
});
});
});
describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state);
......@@ -96,17 +112,17 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.REQUEST_UPDATE_RELEASE}`, () => {
describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_UPDATE_RELEASE](state);
mutations[types.REQUEST_SAVE_RELEASE](state);
expect(state.isUpdatingRelease).toBe(true);
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release);
expect(state.updateError).toBeUndefined();
......@@ -114,10 +130,10 @@ describe('Release detail mutations', () => {
});
});
describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => {
describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error);
mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error);
expect(state.isUpdatingRelease).toBe(false);
......
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