Commit d75f02b2 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'nfriend-convert-release-new-edit-page-to-graphql' into 'master'

Convert New and Edit Release pages to use GraphQL

See merge request gitlab-org/gitlab!57000
parents 21b79e1a 51b2591b
mutation createRelease($input: ReleaseCreateInput!) {
releaseCreate(input: $input) {
release {
links {
selfUrl
}
}
errors
}
}
mutation createReleaseAssetLink($input: ReleaseAssetLinkCreateInput!) {
releaseAssetLinkCreate(input: $input) {
errors
}
}
mutation deleteReleaseAssetLink($input: ReleaseAssetLinkDeleteInput!) {
releaseAssetLinkDelete(input: $input) {
errors
}
}
mutation updateRelease($input: ReleaseUpdateInput!) {
releaseUpdate(input: $input) {
errors
}
}
import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import oneReleaseQuery from '~/releases/queries/one_release.query.graphql';
import {
releaseToApiJson,
apiJsonToRelease,
gqClient,
convertOneReleaseGraphQLResponse,
} from '~/releases/util';
import createReleaseMutation from '~/releases/queries/create_release.mutation.graphql';
import createReleaseAssetLinkMutation from '~/releases/queries/create_release_link.mutation.graphql';
import deleteReleaseAssetLinkMutation from '~/releases/queries/delete_release_link.mutation.graphql';
import oneReleaseForEditingQuery from '~/releases/queries/one_release_for_editing.query.graphql';
import updateReleaseMutation from '~/releases/queries/update_release.mutation.graphql';
import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
import * as types from './mutation_types';
export const initializeRelease = ({ commit, dispatch, getters }) => {
......@@ -24,38 +22,25 @@ export const initializeRelease = ({ commit, dispatch, getters }) => {
return Promise.resolve();
};
export const fetchRelease = ({ commit, state, rootState }) => {
export const fetchRelease = async ({ commit, state }) => {
commit(types.REQUEST_RELEASE);
if (rootState.featureFlags?.graphqlIndividualReleasePage) {
return gqClient
.query({
query: oneReleaseQuery,
variables: {
fullPath: state.projectPath,
tagName: state.tagName,
},
})
.then((response) => {
const { data: release } = convertOneReleaseGraphQLResponse(response);
commit(types.RECEIVE_RELEASE_SUCCESS, release);
})
.catch((error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details.'));
});
}
return api
.release(state.projectId, state.tagName)
.then(({ data }) => {
commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data));
})
.catch((error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details.'));
try {
const fetchResponse = await gqClient.query({
query: oneReleaseForEditingQuery,
variables: {
fullPath: state.projectPath,
tagName: state.tagName,
},
});
const { data: release } = convertOneReleaseGraphQLResponse(fetchResponse);
commit(types.RECEIVE_RELEASE_SUCCESS, release);
} catch (error) {
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details.'));
}
};
export const updateReleaseTagName = ({ commit }, tagName) =>
......@@ -94,9 +79,9 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
export const receiveSaveReleaseSuccess = ({ commit }, release) => {
export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
redirectTo(release._links.self);
redirectTo(urlToRedirectTo);
};
export const saveRelease = ({ commit, dispatch, getters }) => {
......@@ -105,83 +90,130 @@ export const saveRelease = ({ commit, dispatch, getters }) => {
dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
};
export const createRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson(
{
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
},
state.createFrom,
);
/**
* Tests a GraphQL mutation response for the existence of any errors-as-data
* (See https://docs.gitlab.com/ee/development/fe_guide/graphql.html#errors-as-data).
* If any errors occurred, throw a JavaScript `Error` object, so that this can be
* handled by the global error handler.
*
* @param {Object} gqlResponse The response object returned by the GraphQL client
* @param {String} mutationName The name of the mutation that was executed
* @param {String} messageIfError An message to build into the error object if something went wrong
*/
const checkForErrorsAsData = (gqlResponse, mutationName, messageIfError) => {
const allErrors = gqlResponse.data[mutationName].errors;
if (allErrors.length > 0) {
const allErrorMessages = JSON.stringify(allErrors);
throw new Error(`${messageIfError}: ${allErrorMessages}`);
}
};
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 createRelease = async ({ commit, dispatch, state, getters }) => {
try {
const response = await gqClient.mutate({
mutation: createReleaseMutation,
variables: getters.releaseCreateMutatationVariables,
});
checkForErrorsAsData(
response,
'releaseCreate',
`Something went wrong while creating a new release with projectPath "${state.projectPath}" and tagName "${state.release.tagName}"`,
);
dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl);
} 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,
/**
* Deletes a single release link.
* Throws an error if any network or validation errors occur.
*/
const deleteReleaseLinks = async ({ state, id }) => {
const deleteResponse = await gqClient.mutate({
mutation: deleteReleaseAssetLinkMutation,
variables: {
input: { id },
},
});
let updatedRelease = null;
return (
api
.updateRelease(state.projectId, state.tagName, apiJson)
/**
* 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(({ data }) => {
// Save this response since we need it later in the Promise chain
updatedRelease = data;
// Delete all links currently associated with this Release
return Promise.all(
getters.releaseLinksToDelete.map((l) =>
api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
),
);
})
.then(() => {
// Create a new link for each link in the form
return Promise.all(
apiJson.assets.links.map((l) =>
api.createReleaseLink(state.projectId, state.release.tagName, l),
),
);
})
.then(() => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
})
.catch((error) => {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details'));
})
checkForErrorsAsData(
deleteResponse,
'releaseAssetLinkDelete',
`Something went wrong while deleting release asset link for release with projectPath "${state.projectPath}", tagName "${state.tagName}", and link id "${id}"`,
);
};
/**
* Creates a single release link.
* Throws an error if any network or validation errors occur.
*/
const createReleaseLink = async ({ state, link }) => {
const createResponse = await gqClient.mutate({
mutation: createReleaseAssetLinkMutation,
variables: {
input: {
projectPath: state.projectPath,
tagName: state.tagName,
name: link.name,
url: link.url,
linkType: link.linkType.toUpperCase(),
},
},
});
checkForErrorsAsData(
createResponse,
'releaseAssetLinkCreate',
`Something went wrong while creating a release asset link for release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
);
};
export const updateRelease = async ({ commit, dispatch, state, getters }) => {
try {
/**
* Currently, we delete all existing links and then
* recreate new ones on each edit. This is because the
* backend 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 take place entirely
* in the backend. This is being discussed in
* https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50300
*/
const updateReleaseResponse = await gqClient.mutate({
mutation: updateReleaseMutation,
variables: getters.releaseUpdateMutatationVariables,
});
checkForErrorsAsData(
updateReleaseResponse,
'releaseUpdate',
`Something went wrong while updating release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
);
// Delete all links currently associated with this Release
await Promise.all(
getters.releaseLinksToDelete.map(({ id }) => deleteReleaseLinks({ state, id })),
);
// Create a new link for each link in the form
await Promise.all(
getters.releaseLinksToCreate.map((link) => createReleaseLink({ state, link })),
);
dispatch('receiveSaveReleaseSuccess', state.release._links.self);
} catch (error) {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details.'));
}
};
......@@ -103,3 +103,39 @@ export const isValid = (_state, getters) => {
const errors = getters.validationErrors;
return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty;
};
/** Returns all the variables for a `releaseUpdate` GraphQL mutation */
export const releaseUpdateMutatationVariables = (state) => {
const name = state.release.name?.trim().length > 0 ? state.release.name.trim() : null;
// Milestones may be either a list of milestone objects OR just a list
// of milestone titles. The GraphQL mutation requires only the titles be sent.
const milestones = (state.release.milestones || []).map((m) => m.title || m);
return {
input: {
projectPath: state.projectPath,
tagName: state.release.tagName,
name,
description: state.release.description,
milestones,
},
};
};
/** Returns all the variables for a `releaseCreate` GraphQL mutation */
export const releaseCreateMutatationVariables = (state, getters) => {
return {
input: {
...getters.releaseUpdateMutatationVariables.input,
ref: state.createFrom,
assets: {
links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({
name,
url,
linkType: linkType.toUpperCase(),
})),
},
},
};
};
import { pick } from 'lodash';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
/**
* Converts a release object into a JSON object that can sent to the public
* API to create or update a release.
* @param {Object} release The release object to convert
* @param {string} createFrom The ref to create a new tag from, if necessary
*/
export const releaseToApiJson = (release, createFrom = null) => {
const name = release.name?.trim().length > 0 ? release.name.trim() : null;
// Milestones may be either a list of milestone objects OR just a list
// of milestone titles. The API requires only the titles be sent.
const milestones = (release.milestones || []).map((m) => m.title || m);
return convertObjectPropsToSnakeCase(
{
name,
tagName: release.tagName,
ref: createFrom,
description: release.description,
milestones,
assets: release.assets,
},
{ deep: true },
);
};
/**
* Converts a JSON release object returned by the Release API
* into the structure this Vue application can work with.
* @param {Object} json The JSON object received from the release API
*/
export const apiJsonToRelease = (json) => {
const release = convertObjectPropsToCamelCase(json, { deep: true });
release.milestones = release.milestones || [];
return release;
};
export const gqClient = createGqClient({}, { fetchPolicy: fetchPolicies.NO_CACHE });
const convertScalarProperties = (graphQLRelease) =>
......@@ -125,8 +82,7 @@ const convertMilestones = (graphQLRelease) => ({
/**
* Converts a single release object fetched from GraphQL
* into a release object that matches the shape of the REST API
* (the same shape that is returned by `apiJsonToRelease` above.)
* into a release object that matches the general structure of the REST API
*
* @param graphQLRelease The release object returned from a GraphQL query
*/
......
---
title: Speed up save on New/Edit Release page
merge_request: 57000
author:
type: performance
......@@ -26663,13 +26663,13 @@ msgstr ""
msgid "Releases|New Release"
msgstr ""
msgid "Release|Something went wrong while creating a new release"
msgid "Release|Something went wrong while creating a new release."
msgstr ""
msgid "Release|Something went wrong while getting the release details."
msgstr ""
msgid "Release|Something went wrong while saving the release details"
msgid "Release|Something went wrong while saving the release details."
msgstr ""
msgid "Remediations"
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash';
import { getJSONFixture } from 'helpers/fixtures';
import testAction from 'helpers/vuex_action_helper';
import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import httpStatus from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility';
import { ASSET_LINK_TYPE } from '~/releases/constants';
import createReleaseAssetLinkMutation from '~/releases/queries/create_release_link.mutation.graphql';
import deleteReleaseAssetLinkMutation from '~/releases/queries/delete_release_link.mutation.graphql';
import updateReleaseMutation from '~/releases/queries/update_release.mutation.graphql';
import * as actions from '~/releases/stores/modules/edit_new/actions';
import * as types from '~/releases/stores/modules/edit_new/mutation_types';
import createState from '~/releases/stores/modules/edit_new/state';
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
jest.mock('~/flash');
......@@ -21,12 +19,21 @@ jest.mock('~/lib/utils/url_utility', () => ({
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
}));
const originalRelease = getJSONFixture('api/releases/release.json');
jest.mock('~/releases/util', () => ({
...jest.requireActual('~/releases/util'),
gqClient: {
query: jest.fn(),
mutate: jest.fn(),
},
}));
const originalOneReleaseForEditingQueryResponse = getJSONFixture(
'graphql/releases/queries/one_release_for_editing.query.graphql.json',
);
describe('Release edit/new actions', () => {
let state;
let release;
let mock;
let releaseResponse;
let error;
const setupState = (updates = {}) => {
......@@ -34,38 +41,26 @@ describe('Release edit/new actions', () => {
isExistingRelease: true,
};
const rootState = {
featureFlags: {
graphqlIndividualReleasePage: false,
},
};
state = {
...createState({
projectId: '18',
tagName: release.tag_name,
tagName: releaseResponse.tag_name,
releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview',
}),
...getters,
...rootState,
...updates,
};
};
beforeEach(() => {
release = cloneDeep(originalRelease);
mock = new MockAdapter(axios);
releaseResponse = cloneDeep(originalOneReleaseForEditingQueryResponse);
gon.api_version = 'v4';
error = { message: 'An error occurred' };
error = new Error('Yikes!');
createFlash.mockClear();
});
afterEach(() => {
mock.restore();
});
describe('when creating a new release', () => {
beforeEach(() => {
setupState({ isExistingRelease: false });
......@@ -118,15 +113,9 @@ describe('Release edit/new actions', () => {
beforeEach(setupState);
describe('fetchRelease', () => {
let getReleaseUrl;
beforeEach(() => {
getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`;
});
describe('when the network request to the Release API is successful', () => {
beforeEach(() => {
mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release);
gqClient.query.mockResolvedValue(releaseResponse);
});
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => {
......@@ -136,15 +125,15 @@ describe('Release edit/new actions', () => {
},
{
type: types.RECEIVE_RELEASE_SUCCESS,
payload: apiJsonToRelease(release, { deep: true }),
payload: convertOneReleaseGraphQLResponse(releaseResponse).data,
},
]);
});
});
describe('when the network request to the Release API fails', () => {
describe('when the GraphQL network request fails', () => {
beforeEach(() => {
mock.onGet(getReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
gqClient.query.mockRejectedValue(error);
});
it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => {
......@@ -282,44 +271,50 @@ describe('Release edit/new actions', () => {
describe('receiveSaveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveSaveReleaseSuccess, release, state, [
testAction(actions.receiveSaveReleaseSuccess, releaseResponse, state, [
{ type: types.RECEIVE_SAVE_RELEASE_SUCCESS },
]));
it("redirects to the release's dedicated page", () => {
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state }, release);
const { selfUrl } = releaseResponse.data.project.release.links;
actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state }, selfUrl);
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(release._links.self);
expect(redirectTo).toHaveBeenCalledWith(selfUrl);
});
});
describe('createRelease', () => {
let createReleaseUrl;
let releaseLinksToCreate;
beforeEach(() => {
const camelCasedRelease = convertObjectPropsToCamelCase(release);
const { data: release } = convertOneReleaseGraphQLResponse(
originalOneReleaseForEditingQueryResponse,
);
releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1);
releaseLinksToCreate = release.assets.links.slice(0, 1);
setupState({
release: camelCasedRelease,
release,
releaseLinksToCreate,
});
createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`;
});
describe('when the network request to the Release API is successful', () => {
describe('when the GraphQL request is successful', () => {
const selfUrl = 'url/to/self';
beforeEach(() => {
const expectedRelease = releaseToApiJson({
...state.release,
assets: {
links: releaseLinksToCreate,
gqClient.mutate.mockResolvedValue({
data: {
releaseCreate: {
release: {
links: {
selfUrl,
},
},
errors: [],
},
},
});
mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release);
});
it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => {
......@@ -331,16 +326,16 @@ describe('Release edit/new actions', () => {
[
{
type: 'receiveSaveReleaseSuccess',
payload: apiJsonToRelease(release, { deep: true }),
payload: selfUrl,
},
],
);
});
});
describe('when the network request to the Release API fails', () => {
describe('when the GraphQL network request fails', () => {
beforeEach(() => {
mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
gqClient.mutate.mockRejectedValue(error);
});
it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => {
......@@ -358,7 +353,7 @@ describe('Release edit/new actions', () => {
.then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while creating a new release',
'Something went wrong while creating a new release.',
);
});
});
......@@ -369,112 +364,209 @@ describe('Release edit/new actions', () => {
let getters;
let dispatch;
let commit;
let callOrder;
let release;
beforeEach(() => {
getters = {
releaseLinksToDelete: [{ id: '1' }, { id: '2' }],
releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }],
releaseLinksToCreate: [
{ id: 'new-link-1', name: 'Link 1', url: 'https://example.com/1', linkType: 'Other' },
{ id: 'new-link-2', name: 'Link 2', url: 'https://example.com/2', linkType: 'Package' },
],
releaseUpdateMutatationVariables: {},
};
release = convertOneReleaseGraphQLResponse(releaseResponse).data;
setupState({
release: convertObjectPropsToCamelCase(release),
release,
...getters,
});
dispatch = jest.fn();
commit = jest.fn();
callOrder = [];
jest.spyOn(api, 'updateRelease').mockImplementation(() => {
callOrder.push('updateRelease');
return Promise.resolve({ data: release });
});
jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => {
callOrder.push('deleteReleaseLink');
return Promise.resolve();
});
jest.spyOn(api, 'createReleaseLink').mockImplementation(() => {
callOrder.push('createReleaseLink');
return Promise.resolve();
gqClient.mutate.mockResolvedValue({
data: {
releaseUpdate: {
errors: [],
},
releaseAssetLinkDelete: {
errors: [],
},
releaseAssetLinkCreate: {
errors: [],
},
},
});
});
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([
['receiveSaveReleaseSuccess', apiJsonToRelease(release)],
]);
});
it('dispatches receiveSaveReleaseSuccess', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
expect(dispatch.mock.calls).toEqual([['receiveSaveReleaseSuccess', release._links.self]]);
});
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',
]);
it('updates the Release, then deletes all existing links, and then recreates new links', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
expect(api.updateRelease.mock.calls).toEqual([
[
state.projectId,
state.tagName,
releaseToApiJson({
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
}),
],
]);
// First, update the release
expect(gqClient.mutate.mock.calls[0]).toEqual([
{
mutation: updateReleaseMutation,
variables: getters.releaseUpdateMutatationVariables,
},
]);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToDelete.length,
);
getters.releaseLinksToDelete.forEach((link) => {
expect(api.deleteReleaseLink).toHaveBeenCalledWith(
state.projectId,
state.tagName,
link.id,
);
});
// Then, delete the first asset link
expect(gqClient.mutate.mock.calls[1]).toEqual([
{
mutation: deleteReleaseAssetLinkMutation,
variables: { input: { id: getters.releaseLinksToDelete[0].id } },
},
]);
expect(api.createReleaseLink).toHaveBeenCalledTimes(
getters.releaseLinksToCreate.length,
);
getters.releaseLinksToCreate.forEach((link) => {
expect(api.createReleaseLink).toHaveBeenCalledWith(
state.projectId,
state.tagName,
link,
);
});
});
// And the second
expect(gqClient.mutate.mock.calls[2]).toEqual([
{
mutation: deleteReleaseAssetLinkMutation,
variables: { input: { id: getters.releaseLinksToDelete[1].id } },
},
]);
// Recreate the first asset link
expect(gqClient.mutate.mock.calls[3]).toEqual([
{
mutation: createReleaseAssetLinkMutation,
variables: {
input: {
projectPath: state.projectPath,
tagName: state.tagName,
name: getters.releaseLinksToCreate[0].name,
url: getters.releaseLinksToCreate[0].url,
linkType: getters.releaseLinksToCreate[0].linkType.toUpperCase(),
},
},
},
]);
// And finally, recreate the second
expect(gqClient.mutate.mock.calls[4]).toEqual([
{
mutation: createReleaseAssetLinkMutation,
variables: {
input: {
projectPath: state.projectPath,
tagName: state.tagName,
name: getters.releaseLinksToCreate[1].name,
url: getters.releaseLinksToCreate[1].url,
linkType: getters.releaseLinksToCreate[1].linkType.toUpperCase(),
},
},
},
]);
});
});
describe('when the network request to the Release API fails', () => {
describe('when the GraphQL network request fails', () => {
beforeEach(() => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error);
gqClient.mutate.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('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]);
});
it('shows a flash message', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while saving the release details.',
);
});
});
describe('when the GraphQL mutation returns errors-as-data', () => {
const expectCorrectErrorHandling = () => {
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
expect(commit.mock.calls).toEqual([
[types.RECEIVE_SAVE_RELEASE_ERROR, expect.any(Error)],
]);
});
it('shows a flash message', async () => {
await actions.updateRelease({ commit, dispatch, state, getters });
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',
'Something went wrong while saving the release details.',
);
});
};
describe('when the releaseUpdate mutation returns errors-as-data', () => {
beforeEach(() => {
gqClient.mutate.mockResolvedValue({
data: {
releaseUpdate: {
errors: ['Something went wrong!'],
},
releaseAssetLinkDelete: {
errors: [],
},
releaseAssetLinkCreate: {
errors: [],
},
},
});
});
expectCorrectErrorHandling();
});
describe('when the releaseAssetLinkDelete mutation returns errors-as-data', () => {
beforeEach(() => {
gqClient.mutate.mockResolvedValue({
data: {
releaseUpdate: {
errors: [],
},
releaseAssetLinkDelete: {
errors: ['Something went wrong!'],
},
releaseAssetLinkCreate: {
errors: [],
},
},
});
});
expectCorrectErrorHandling();
});
describe('when the releaseAssetLinkCreate mutation returns errors-as-data', () => {
beforeEach(() => {
gqClient.mutate.mockResolvedValue({
data: {
releaseUpdate: {
errors: [],
},
releaseAssetLinkDelete: {
errors: [],
},
releaseAssetLinkCreate: {
errors: ['Something went wrong!'],
},
},
});
});
expectCorrectErrorHandling();
});
});
});
......
......@@ -257,4 +257,93 @@ describe('Release edit/new getters', () => {
});
});
});
describe.each([
[
'returns all the data needed for the releaseUpdate GraphQL query',
{
projectPath: 'projectPath',
release: {
tagName: 'release.tagName',
name: 'release.name',
description: 'release.description',
milestones: ['release.milestone[0].title'],
},
},
{
projectPath: 'projectPath',
tagName: 'release.tagName',
name: 'release.name',
description: 'release.description',
milestones: ['release.milestone[0].title'],
},
],
[
'trims whitespace from the release name',
{ release: { name: ' name \t\n' } },
{ name: 'name' },
],
[
'returns the name as null if the name is nothing but whitespace',
{ release: { name: ' \t\n' } },
{ name: null },
],
['returns the name as null if the name is undefined', { release: {} }, { name: null }],
[
'returns just the milestone titles even if the release includes full milestone objects',
{ release: { milestones: [{ title: 'release.milestone[0].title' }] } },
{ milestones: ['release.milestone[0].title'] },
],
])('releaseUpdateMutatationVariables', (description, state, expectedVariables) => {
it(description, () => {
const expectedVariablesObject = { input: expect.objectContaining(expectedVariables) };
const actualVariables = getters.releaseUpdateMutatationVariables(state);
expect(actualVariables).toEqual(expectedVariablesObject);
});
});
describe('releaseCreateMutatationVariables', () => {
it('returns all the data needed for the releaseCreate GraphQL query', () => {
const state = {
createFrom: 'main',
};
const otherGetters = {
releaseUpdateMutatationVariables: {
input: {
name: 'release.name',
},
},
releaseLinksToCreate: [
{
name: 'link.name',
url: 'link.url',
linkType: 'link.linkType',
},
],
};
const expectedVariables = {
input: {
name: 'release.name',
ref: 'main',
assets: {
links: [
{
name: 'link.name',
url: 'link.url',
linkType: 'LINK.LINKTYPE',
},
],
},
},
};
const actualVariables = getters.releaseCreateMutatationVariables(state, otherGetters);
expect(actualVariables).toEqual(expectedVariables);
});
});
});
import { cloneDeep } from 'lodash';
import { getJSONFixture } from 'helpers/fixtures';
import {
releaseToApiJson,
apiJsonToRelease,
convertGraphQLRelease,
convertAllReleasesGraphQLResponse,
convertOneReleaseGraphQLResponse,
......@@ -19,106 +17,6 @@ const originalOneReleaseForEditingQueryResponse = getJSONFixture(
);
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: ['13.2', '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('release.name', () => {
it.each`
input | output
${null} | ${null}
${''} | ${null}
${' \t\n\r\n'} | ${null}
${' Release name '} | ${'Release name'}
`('converts a name like `$input` to `$output`', ({ input, output }) => {
const release = { name: input };
const expectedJson = {
name: output,
};
expect(releaseToApiJson(release)).toMatchObject(expectedJson);
});
});
describe('when milestones contains full milestone objects', () => {
it('converts the milestone objects into titles', () => {
const release = {
milestones: [{ title: '13.2' }, { title: '13.3' }, '13.4'],
};
const expectedJson = { milestones: ['13.2', '13.3', '13.4'] };
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);
});
});
describe('convertGraphQLRelease', () => {
let releaseFromResponse;
let convertedRelease;
......
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