Commit c9743f0d authored by Mark Florian's avatar Mark Florian

Merge branch 'nfriend-add-link-assets-to-release-form' into 'master'

Add link asset fields to "Edit Release" form

See merge request gitlab-org/gitlab!26821
parents 924829f0 08ce3cb7
......@@ -41,6 +41,8 @@ const Api = {
createBranchPath: '/api/:version/projects/:id/repository/branches',
releasesPath: '/api/:version/projects/:id/releases',
releasePath: '/api/:version/projects/:id/releases/:tag_name',
releaseLinksPath: '/api/:version/projects/:id/releases/:tag_name/assets/links',
releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
......@@ -460,6 +462,23 @@ const Api = {
return axios.put(url, release);
},
createReleaseLink(projectPath, tagName, link) {
const url = Api.buildUrl(this.releaseLinksPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':tag_name', encodeURIComponent(tagName));
return axios.post(url, link);
},
deleteReleaseLink(projectPath, tagName, linkId) {
const url = Api.buildUrl(this.releaseLinkPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':tag_name', encodeURIComponent(tagName))
.replace(':link_id', encodeURIComponent(linkId));
return axios.delete(url);
},
adminStatistics() {
const url = Api.buildUrl(this.adminStatisticsPath);
return axios.get(url);
......
......@@ -7,6 +7,8 @@ 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';
export default {
name: 'ReleaseEditApp',
......@@ -16,10 +18,12 @@ export default {
GlButton,
GlLink,
MarkdownField,
AssetLinksForm,
},
directives: {
autofocusonshow,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', [
'isFetchingRelease',
......@@ -80,6 +84,9 @@ export default {
cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
},
showAssetLinksForm() {
return this.glFeatures.releaseAssetLinkEditing;
},
},
created() {
this.fetchRelease();
......@@ -153,6 +160,8 @@ export default {
</div>
</gl-form-group>
<asset-links-form v-if="showAssetLinksForm" />
<div class="d-flex pt-3">
<gl-button
class="mr-auto js-submit-button"
......
<script>
import { mapState, mapActions } from 'vuex';
import {
GlSprintf,
GlLink,
GlFormGroup,
GlButton,
GlIcon,
GlTooltipDirective,
GlFormInput,
} from '@gitlab/ui';
export default {
name: 'AssetLinksForm',
components: { GlSprintf, GlLink, GlFormGroup, GlButton, GlIcon, GlFormInput },
directives: { GlTooltip: GlTooltipDirective },
computed: {
...mapState('detail', ['release', 'releaseAssetsDocsPath']),
},
created() {
this.addEmptyAssetLink();
},
methods: {
...mapActions('detail', [
'addEmptyAssetLink',
'updateAssetLinkUrl',
'updateAssetLinkName',
'removeAssetLink',
]),
onAddAnotherClicked() {
this.addEmptyAssetLink();
},
onRemoveClicked(linkId) {
this.removeAssetLink(linkId);
},
onUrlInput(linkIdToUpdate, newUrl) {
this.updateAssetLinkUrl({ linkIdToUpdate, newUrl });
},
onLinkTitleInput(linkIdToUpdate, newName) {
this.updateAssetLinkName({ linkIdToUpdate, newName });
},
},
};
</script>
<template>
<div class="d-flex flex-column release-assets-links-form">
<h2 class="text-4">{{ __('Release assets') }}</h2>
<p class="m-0">
<gl-sprintf
:message="
__(
'Add %{linkStart}assets%{linkEnd} to your Release. GitLab automatically includes read-only assets, like source code and release evidence.',
)
"
>
<template #link="{ content }">
<gl-link
:href="releaseAssetsDocsPath"
target="_blank"
:aria-label="__('Release assets documentation')"
>
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
<h3 class="text-3">{{ __('Links') }}</h3>
<p>
{{
__(
'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance.',
)
}}
</p>
<div
v-for="(link, index) in release.assets.links"
:key="link.id"
class="d-flex flex-column flex-sm-row align-items-stretch align-items-sm-end"
>
<gl-form-group
class="url-field form-group flex-grow-1 mr-sm-4"
:label="__('URL')"
:label-for="`asset-url-${index}`"
>
<gl-form-input
:id="`asset-url-${index}`"
:value="link.url"
type="text"
class="form-control"
@change="onUrlInput(link.id, $event)"
/>
</gl-form-group>
<gl-form-group
class="link-title-field flex-grow-1 mr-sm-4"
:label="__('Link title')"
:label-for="`asset-link-name-${index}`"
>
<gl-form-input
:id="`asset-link-name-${index}`"
:value="link.name"
type="text"
class="form-control"
@change="onLinkTitleInput(link.id, $event)"
/>
</gl-form-group>
<gl-button
v-gl-tooltip
class="mb-5 mb-sm-3 flex-grow-0 flex-shrink-0 remove-button"
:aria-label="__('Remove asset link')"
:title="__('Remove asset link')"
@click="onRemoveClicked(link.id)"
>
<gl-icon class="m-0" name="remove" />
<span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span>
</gl-button>
</div>
<gl-button variant="link" class="align-self-end mb-5 mb-sm-0" @click="onAddAnotherClicked">
{{ __('Add another link') }}
</gl-button>
</div>
</template>
......@@ -41,20 +41,74 @@ export const receiveUpdateReleaseError = ({ commit }, error) => {
createFlash(s__('Release|Something went wrong while saving the release details'));
};
export const updateRelease = ({ dispatch, state }) => {
export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
return api
.updateRelease(state.projectId, state.tagName, {
name: state.release.name,
description: state.release.description,
})
.then(() => dispatch('receiveUpdateReleaseSuccess'))
.catch(error => {
dispatch('receiveUpdateReleaseError', error);
});
const { release } = state;
return (
api
.updateRelease(state.projectId, state.tagName, {
name: release.name,
description: release.description,
})
/**
* Currently, we delete all existing links and then
* recreate new ones on each edit. This is because the
* REST API doesn't support bulk updating of Release links,
* and updating individual links can lead to validation
* race conditions (in particular, the "URLs must be unique")
* constraint.
*
* This isn't ideal since this is no longer an atomic
* operation - parts of it can fail while others succeed,
* leaving the Release in an inconsistent state.
*
* This logic should be refactored to use GraphQL once
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702
* is closed.
*/
.then(() => {
// Delete all links currently associated with this Release
return Promise.all(
getters.releaseLinksToDelete.map(l =>
api.deleteReleaseLink(state.projectId, release.tagName, l.id),
),
);
})
.then(() => {
// Create a new link for each link in the form
return Promise.all(
getters.releaseLinksToCreate.map(l =>
api.createReleaseLink(state.projectId, release.tagName, l),
),
);
})
.then(() => dispatch('receiveUpdateReleaseSuccess'))
.catch(error => {
dispatch('receiveUpdateReleaseError', error);
})
);
};
export const navigateToReleasesPage = ({ state }) => {
redirectTo(state.releasesPagePath);
};
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
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 removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
/**
* @returns {Boolean} `true` if the release link is empty, i.e. it has
* empty (or whitespace-only) values for both `url` and `name`.
* Otherwise, `false`.
*/
const isEmptyReleaseLink = l => !/\S/.test(l.url) && !/\S/.test(l.name);
/** Returns all release links that aren't empty */
export const releaseLinksToCreate = state => {
if (!state.release) {
return [];
}
return state.release.assets.links.filter(l => !isEmptyReleaseLink(l));
};
/** Returns all release links that should be deleted */
export const releaseLinksToDelete = state => {
if (!state.originalRelease) {
return [];
}
return state.originalRelease.assets.links;
};
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
export default initialState => ({
namespaced: true,
actions,
getters,
mutations,
state: createState(initialState),
});
......@@ -8,3 +8,8 @@ export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
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 ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK';
export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL';
export const UPDATE_ASSET_LINK_NAME = 'UPDATE_ASSET_LINK_NAME';
export const REMOVE_ASSET_LINK = 'REMOVE_ASSET_LINK';
import * as types from './mutation_types';
import { uniqueId, cloneDeep } from 'lodash';
const findReleaseLink = (release, id) => {
return release.assets.links.find(l => l.id === id);
};
export default {
[types.REQUEST_RELEASE](state) {
......@@ -8,6 +13,7 @@ export default {
state.fetchError = undefined;
state.isFetchingRelease = false;
state.release = data;
state.originalRelease = Object.freeze(cloneDeep(state.release));
},
[types.RECEIVE_RELEASE_ERROR](state, error) {
state.fetchError = error;
......@@ -33,4 +39,26 @@ export default {
state.updateError = error;
state.isUpdatingRelease = false;
},
[types.ADD_EMPTY_ASSET_LINK](state) {
state.release.assets.links.push({
id: uniqueId('new-link-'),
url: '',
name: '',
});
},
[types.UPDATE_ASSET_LINK_URL](state, { linkIdToUpdate, newUrl }) {
const linkToUpdate = findReleaseLink(state.release, linkIdToUpdate);
linkToUpdate.url = newUrl;
},
[types.UPDATE_ASSET_LINK_NAME](state, { linkIdToUpdate, newName }) {
const linkToUpdate = findReleaseLink(state.release, linkIdToUpdate);
linkToUpdate.name = newName;
},
[types.REMOVE_ASSET_LINK](state, linkIdToRemove) {
state.release.assets.links = state.release.assets.links.filter(l => l.id !== linkIdToRemove);
},
};
......@@ -5,6 +5,7 @@ export default ({
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
}) => ({
projectId,
tagName,
......@@ -12,9 +13,18 @@ export default ({
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
/** The Release object */
release: null,
/**
* A deep clone of the Release object above.
* Used when editing this Release so that
* changes can be computed.
*/
originalRelease: null,
isFetchingRelease: false,
fetchError: null,
......
......@@ -9,6 +9,7 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:release_issue_summary, project, default_enabled: true)
push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true)
push_frontend_feature_flag(:release_show_page, project, default_enabled: true)
push_frontend_feature_flag(:release_asset_link_editing, project)
end
before_action :authorize_update_release!, only: %i[edit update]
......
......@@ -8,8 +8,8 @@ module ReleasesHelper
image_path(IMAGE_PATH)
end
def help_page
help_page_path(DOCUMENTATION_PATH)
def help_page(anchor: nil)
help_page_path(DOCUMENTATION_PATH, anchor: anchor)
end
def data_for_releases_page
......@@ -29,7 +29,8 @@ module ReleasesHelper
markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'),
releases_page_path: project_releases_path(@project, anchor: @release.tag),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release')
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),
release_assets_docs_path: help_page(anchor: 'release-assets')
}
end
end
......@@ -1068,6 +1068,9 @@ msgid_plural "Add %d issues"
msgstr[0] ""
msgstr[1] ""
msgid "Add %{linkStart}assets%{linkEnd} to your Release. GitLab automatically includes read-only assets, like source code and release evidence."
msgstr ""
msgid "Add CHANGELOG"
msgstr ""
......@@ -1146,6 +1149,9 @@ msgstr ""
msgid "Add an issue"
msgstr ""
msgid "Add another link"
msgstr ""
msgid "Add approval rule"
msgstr ""
......@@ -12008,6 +12014,9 @@ msgstr ""
msgid "Link copied"
msgstr ""
msgid "Link title"
msgstr ""
msgid "Linked emails (%{email_count})"
msgstr ""
......@@ -14809,6 +14818,9 @@ msgstr ""
msgid "Pods in use"
msgstr ""
msgid "Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance."
msgstr ""
msgid "Preferences"
msgstr ""
......@@ -16553,6 +16565,12 @@ msgid_plural "Releases"
msgstr[0] ""
msgstr[1] ""
msgid "Release assets"
msgstr ""
msgid "Release assets documentation"
msgstr ""
msgid "Release does not have the same project as the milestone"
msgstr ""
......@@ -16622,6 +16640,9 @@ msgstr ""
msgid "Remove approvers?"
msgstr ""
msgid "Remove asset link"
msgstr ""
msgid "Remove assignee"
msgstr ""
......
......@@ -570,4 +570,65 @@ describe('Api', () => {
.catch(done.fail);
});
});
describe('createReleaseLink', () => {
const dummyProjectPath = 'gitlab-org/gitlab';
const dummyReleaseTag = 'v1.3';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent(
dummyProjectPath,
)}/releases/${dummyReleaseTag}/assets/links`;
const expectedLink = {
url: 'https://example.com',
name: 'An example link',
};
describe('when the Release is successfully created', () => {
it('resolves the Promise', () => {
mock.onPost(expectedUrl, expectedLink).replyOnce(201);
return Api.createReleaseLink(dummyProjectPath, dummyReleaseTag, expectedLink).then(() => {
expect(mock.history.post).toHaveLength(1);
});
});
});
describe('when an error occurs while creating the Release', () => {
it('rejects the Promise', () => {
mock.onPost(expectedUrl, expectedLink).replyOnce(500);
return Api.createReleaseLink(dummyProjectPath, dummyReleaseTag, expectedLink).catch(() => {
expect(mock.history.post).toHaveLength(1);
});
});
});
});
describe('deleteReleaseLink', () => {
const dummyProjectPath = 'gitlab-org/gitlab';
const dummyReleaseTag = 'v1.3';
const dummyLinkId = '4';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent(
dummyProjectPath,
)}/releases/${dummyReleaseTag}/assets/links/${dummyLinkId}`;
describe('when the Release is successfully deleted', () => {
it('resolves the Promise', () => {
mock.onDelete(expectedUrl).replyOnce(200);
return Api.deleteReleaseLink(dummyProjectPath, dummyReleaseTag, dummyLinkId).then(() => {
expect(mock.history.delete).toHaveLength(1);
});
});
});
describe('when an error occurs while deleting the Release', () => {
it('rejects the Promise', () => {
mock.onDelete(expectedUrl).replyOnce(500);
return Api.deleteReleaseLink(dummyProjectPath, dummyReleaseTag, dummyLinkId).catch(() => {
expect(mock.history.delete).toHaveLength(1);
});
});
});
});
});
......@@ -4,6 +4,7 @@ import ReleaseEditApp from '~/releases/components/app_edit.vue';
import { release as originalRelease } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
describe('Release edit component', () => {
let wrapper;
......@@ -11,7 +12,7 @@ describe('Release edit component', () => {
let actions;
let state;
const factory = () => {
const factory = (featureFlags = {}) => {
state = {
release,
markdownDocsPath: 'path/to/markdown/docs',
......@@ -22,6 +23,7 @@ describe('Release edit component', () => {
actions = {
fetchRelease: jest.fn(),
updateRelease: jest.fn(),
addEmptyAssetLink: jest.fn(),
};
const store = new Vuex.Store({
......@@ -36,6 +38,9 @@ describe('Release edit component', () => {
wrapper = mount(ReleaseEditApp, {
store,
provide: {
glFeatures: featureFlags,
},
});
};
......@@ -132,4 +137,28 @@ describe('Release edit component', () => {
expect(cancelButton.attributes().href).toBe(backUrl);
});
});
describe('asset links form', () => {
const findAssetLinksForm = () => wrapper.find(AssetLinksForm);
describe('when the release_asset_link_editing feature flag is disabled', () => {
beforeEach(() => {
factory({ releaseAssetLinkEditing: false });
});
it('does not render the asset links portion of the form', () => {
expect(findAssetLinksForm().exists()).toBe(false);
});
});
describe('when the release_asset_link_editing feature flag is enabled', () => {
beforeEach(() => {
factory({ releaseAssetLinkEditing: true });
});
it('renders the asset links portion of the form', () => {
expect(findAssetLinksForm().exists()).toBe(true);
});
});
});
});
......@@ -9,6 +9,7 @@ import createState from '~/releases/stores/modules/detail/state';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import api from '~/api';
jest.mock('~/flash', () => jest.fn());
......@@ -179,40 +180,92 @@ describe('Release detail actions', () => {
});
describe('updateRelease', () => {
let getReleaseUrl;
let getters;
let dispatch;
let callOrder;
beforeEach(() => {
state.release = release;
state.release = convertObjectPropsToCamelCase(release);
state.projectId = '18';
state.tagName = 'v1.3';
getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
});
state.tagName = state.release.tagName;
it(`dispatches requestUpdateRelease and receiveUpdateReleaseSuccess`, () => {
mock.onPut(getReleaseUrl).replyOnce(200);
getters = {
releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
};
return testAction(
actions.updateRelease,
undefined,
state,
[],
[{ type: 'requestUpdateRelease' }, { type: 'receiveUpdateReleaseSuccess' }],
);
dispatch = jest.fn();
callOrder = [];
jest.spyOn(api, 'updateRelease').mockImplementation(() => {
callOrder.push('updateRelease');
return Promise.resolve();
});
jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
callOrder.push('deleteReleaseLink');
return Promise.resolve();
});
jest.spyOn(api, 'createReleaseLink').mockImplementation(() => {
callOrder.push('createReleaseLink');
return Promise.resolve();
});
});
it(`dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object`, () => {
mock.onPut(getReleaseUrl).replyOnce(500);
it('dispatches requestUpdateRelease and receiveUpdateReleaseSuccess', () => {
return actions.updateRelease({ dispatch, state, getters }).then(() => {
expect(dispatch.mock.calls).toEqual([
['requestUpdateRelease'],
['receiveUpdateReleaseSuccess'],
]);
});
});
return testAction(
actions.updateRelease,
undefined,
state,
[],
[
{ type: 'requestUpdateRelease' },
{ type: 'receiveUpdateReleaseError', payload: expect.anything() },
],
);
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],
]);
});
});
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,
{
name: state.release.name,
description: state.release.description,
},
],
]);
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);
});
});
});
});
});
import * as getters from '~/releases/stores/modules/detail/getters';
describe('Release detail getters', () => {
describe('releaseLinksToCreate', () => {
it("returns an empty array if state.release doesn't exist", () => {
const state = {};
expect(getters.releaseLinksToCreate(state)).toEqual([]);
});
it("returns all release links that aren't empty", () => {
const emptyLinks = [
{ url: '', name: '' },
{ url: ' ', name: '' },
{ url: ' ', name: ' ' },
{ url: '\r\n', name: '\t' },
];
const nonEmptyLinks = [
{ url: 'https://example.com/1', name: 'Example 1' },
{ url: '', name: 'Example 2' },
{ url: 'https://example.com/3', name: '' },
];
const state = {
release: {
assets: {
links: [...emptyLinks, ...nonEmptyLinks],
},
},
};
expect(getters.releaseLinksToCreate(state)).toEqual(nonEmptyLinks);
});
});
describe('releaseLinksToDelete', () => {
it("returns an empty array if state.originalRelease doesn't exist", () => {
const state = {};
expect(getters.releaseLinksToDelete(state)).toEqual([]);
});
it('returns all links associated with the original release', () => {
const originalLinks = [
{ url: 'https://example.com/1', name: 'Example 1' },
{ url: 'https://example.com/2', name: 'Example 2' },
];
const state = {
originalRelease: {
assets: {
links: originalLinks,
},
},
};
expect(getters.releaseLinksToDelete(state)).toEqual(originalLinks);
});
});
});
......@@ -8,11 +8,12 @@
import createState from '~/releases/stores/modules/detail/state';
import mutations from '~/releases/stores/modules/detail/mutations';
import * as types from '~/releases/stores/modules/detail/mutation_types';
import { release } from '../../../mock_data';
import { release as originalRelease } from '../../../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
describe('Release detail mutations', () => {
let state;
let releaseClone;
let release;
beforeEach(() => {
state = createState({
......@@ -23,7 +24,7 @@ describe('Release detail mutations', () => {
markdownPreviewPath: 'path/to/markdown/preview',
updateReleaseApiDocsPath: 'path/to/api/docs',
});
releaseClone = JSON.parse(JSON.stringify(release));
release = convertObjectPropsToCamelCase(originalRelease);
});
describe(types.REQUEST_RELEASE, () => {
......@@ -36,13 +37,15 @@ describe('Release detail mutations', () => {
describe(types.RECEIVE_RELEASE_SUCCESS, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_RELEASE_SUCCESS](state, releaseClone);
mutations[types.RECEIVE_RELEASE_SUCCESS](state, release);
expect(state.fetchError).toEqual(undefined);
expect(state.isFetchingRelease).toEqual(false);
expect(state.release).toEqual(releaseClone);
expect(state.release).toEqual(release);
expect(state.originalRelease).toEqual(release);
});
});
......@@ -61,7 +64,7 @@ describe('Release detail mutations', () => {
describe(types.UPDATE_RELEASE_TITLE, () => {
it("updates the release's title", () => {
state.release = releaseClone;
state.release = release;
const newTitle = 'The new release title';
mutations[types.UPDATE_RELEASE_TITLE](state, newTitle);
......@@ -71,7 +74,7 @@ describe('Release detail mutations', () => {
describe(types.UPDATE_RELEASE_NOTES, () => {
it("updates the release's notes", () => {
state.release = releaseClone;
state.release = release;
const newNotes = 'The new release notes';
mutations[types.UPDATE_RELEASE_NOTES](state, newNotes);
......@@ -89,7 +92,7 @@ describe('Release detail mutations', () => {
describe(types.RECEIVE_UPDATE_RELEASE_SUCCESS, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, releaseClone);
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
expect(state.updateError).toEqual(undefined);
......@@ -107,4 +110,65 @@ describe('Release detail mutations', () => {
expect(state.updateError).toEqual(error);
});
});
describe(types.ADD_EMPTY_ASSET_LINK, () => {
it('adds a new, empty link object to the release', () => {
state.release = release;
const linksBefore = [...state.release.assets.links];
mutations[types.ADD_EMPTY_ASSET_LINK](state);
expect(state.release.assets.links).toEqual([
...linksBefore,
{
id: expect.stringMatching(/^new-link-/),
url: '',
name: '',
},
]);
});
});
describe(types.UPDATE_ASSET_LINK_URL, () => {
it('updates an asset link with a new URL', () => {
state.release = release;
const newUrl = 'https://example.com/updated/url';
mutations[types.UPDATE_ASSET_LINK_URL](state, {
linkIdToUpdate: state.release.assets.links[0].id,
newUrl,
});
expect(state.release.assets.links[0].url).toEqual(newUrl);
});
});
describe(types.UPDATE_ASSET_LINK_NAME, () => {
it('updates an asset link with a new name', () => {
state.release = release;
const newName = 'Updated Link';
mutations[types.UPDATE_ASSET_LINK_NAME](state, {
linkIdToUpdate: state.release.assets.links[0].id,
newName,
});
expect(state.release.assets.links[0].name).toEqual(newName);
});
});
describe(types.REMOVE_ASSET_LINK, () => {
it('removes an asset link from the release', () => {
state.release = release;
const linkToRemove = state.release.assets.links[0];
mutations[types.REMOVE_ASSET_LINK](state, linkToRemove.id);
expect(state.release.assets.links).not.toContainEqual(linkToRemove);
});
});
});
......@@ -53,7 +53,8 @@ describe ReleasesHelper do
markdown_preview_path
markdown_docs_path
releases_page_path
update_release_api_docs_path)
update_release_api_docs_path
release_assets_docs_path)
expect(helper.data_for_edit_release_page.keys).to eq(keys)
end
end
......
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