Commit 08ce3cb7 authored by Nathan Friend's avatar Nathan Friend

Add asset link editing to "Edit Release" page

This commit adds basic asset link editing to the existing "Edit Release"
form.
parent 963211ec
......@@ -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
......@@ -1060,6 +1060,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 ""
......@@ -1138,6 +1141,9 @@ msgstr ""
msgid "Add an issue"
msgstr ""
msgid "Add another link"
msgstr ""
msgid "Add approval rule"
msgstr ""
......@@ -11994,6 +12000,9 @@ msgstr ""
msgid "Link copied"
msgstr ""
msgid "Link title"
msgstr ""
msgid "Linked emails (%{email_count})"
msgstr ""
......@@ -14795,6 +14804,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 ""
......@@ -16539,6 +16551,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 ""
......@@ -16608,6 +16626,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