Commit 51b2591b authored by Nathan Friend's avatar Nathan Friend Committed by Jose Ivan Vargas

Convert New and Edit Release pages to use GraphQL

parent be8cc402
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 { deprecatedCreateFlash as createFlash } from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import oneReleaseQuery from '~/releases/queries/one_release.query.graphql'; import createReleaseMutation from '~/releases/queries/create_release.mutation.graphql';
import { import createReleaseAssetLinkMutation from '~/releases/queries/create_release_link.mutation.graphql';
releaseToApiJson, import deleteReleaseAssetLinkMutation from '~/releases/queries/delete_release_link.mutation.graphql';
apiJsonToRelease, import oneReleaseForEditingQuery from '~/releases/queries/one_release_for_editing.query.graphql';
gqClient, import updateReleaseMutation from '~/releases/queries/update_release.mutation.graphql';
convertOneReleaseGraphQLResponse, import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
} from '~/releases/util';
import * as types from './mutation_types'; import * as types from './mutation_types';
export const initializeRelease = ({ commit, dispatch, getters }) => { export const initializeRelease = ({ commit, dispatch, getters }) => {
...@@ -24,38 +22,25 @@ export const initializeRelease = ({ commit, dispatch, getters }) => { ...@@ -24,38 +22,25 @@ export const initializeRelease = ({ commit, dispatch, getters }) => {
return Promise.resolve(); return Promise.resolve();
}; };
export const fetchRelease = ({ commit, state, rootState }) => { export const fetchRelease = async ({ commit, state }) => {
commit(types.REQUEST_RELEASE); commit(types.REQUEST_RELEASE);
if (rootState.featureFlags?.graphqlIndividualReleasePage) { try {
return gqClient const fetchResponse = await gqClient.query({
.query({ query: oneReleaseForEditingQuery,
query: oneReleaseQuery,
variables: { variables: {
fullPath: state.projectPath, fullPath: state.projectPath,
tagName: state.tagName, tagName: state.tagName,
}, },
}) });
.then((response) => {
const { data: release } = convertOneReleaseGraphQLResponse(response); const { data: release } = convertOneReleaseGraphQLResponse(fetchResponse);
commit(types.RECEIVE_RELEASE_SUCCESS, release); commit(types.RECEIVE_RELEASE_SUCCESS, release);
}) } catch (error) {
.catch((error) => {
commit(types.RECEIVE_RELEASE_ERROR, error); commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details.')); 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.'));
});
}; };
export const updateReleaseTagName = ({ commit }, tagName) => export const updateReleaseTagName = ({ commit }, tagName) =>
...@@ -94,9 +79,9 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => { ...@@ -94,9 +79,9 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove); commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
}; };
export const receiveSaveReleaseSuccess = ({ commit }, release) => { export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS); commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
redirectTo(release._links.self); redirectTo(urlToRedirectTo);
}; };
export const saveRelease = ({ commit, dispatch, getters }) => { export const saveRelease = ({ commit, dispatch, getters }) => {
...@@ -105,46 +90,94 @@ export const saveRelease = ({ commit, dispatch, getters }) => { ...@@ -105,46 +90,94 @@ export const saveRelease = ({ commit, dispatch, getters }) => {
dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease'); dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
}; };
export const createRelease = ({ commit, dispatch, state, getters }) => { /**
const apiJson = releaseToApiJson( * 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).
...state.release, * If any errors occurred, throw a JavaScript `Error` object, so that this can be
assets: { * handled by the global error handler.
links: getters.releaseLinksToCreate, *
}, * @param {Object} gqlResponse The response object returned by the GraphQL client
}, * @param {String} mutationName The name of the mutation that was executed
state.createFrom, * @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}`);
}
};
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}"`,
); );
return api dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl);
.createRelease(state.projectId, apiJson) } catch (error) {
.then(({ data }) => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data));
})
.catch((error) => {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while creating a new release')); createFlash(s__('Release|Something went wrong while creating a new release.'));
}); }
}; };
export const updateRelease = ({ commit, dispatch, state, getters }) => { /**
const apiJson = releaseToApiJson({ * Deletes a single release link.
...state.release, * Throws an error if any network or validation errors occur.
assets: { */
links: getters.releaseLinksToCreate, const deleteReleaseLinks = async ({ state, id }) => {
const deleteResponse = await gqClient.mutate({
mutation: deleteReleaseAssetLinkMutation,
variables: {
input: { id },
}, },
}); });
let updatedRelease = null; checkForErrorsAsData(
deleteResponse,
'releaseAssetLinkDelete',
`Something went wrong while deleting release asset link for release with projectPath "${state.projectPath}", tagName "${state.tagName}", and link id "${id}"`,
);
};
return ( /**
api * Creates a single release link.
.updateRelease(state.projectId, state.tagName, apiJson) * 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 * Currently, we delete all existing links and then
* recreate new ones on each edit. This is because the * recreate new ones on each edit. This is because the
* REST API doesn't support bulk updating of Release links, * backend doesn't support bulk updating of Release links,
* and updating individual links can lead to validation * and updating individual links can lead to validation
* race conditions (in particular, the "URLs must be unique") * race conditions (in particular, the "URLs must be unique")
* constraint. * constraint.
...@@ -153,35 +186,34 @@ export const updateRelease = ({ commit, dispatch, state, getters }) => { ...@@ -153,35 +186,34 @@ export const updateRelease = ({ commit, dispatch, state, getters }) => {
* operation - parts of it can fail while others succeed, * operation - parts of it can fail while others succeed,
* leaving the Release in an inconsistent state. * leaving the Release in an inconsistent state.
* *
* This logic should be refactored to use GraphQL once * This logic should be refactored to take place entirely
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702 * in the backend. This is being discussed in
* is closed. * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50300
*/ */
.then(({ data }) => { const updateReleaseResponse = await gqClient.mutate({
// Save this response since we need it later in the Promise chain mutation: updateReleaseMutation,
updatedRelease = data; 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 // Delete all links currently associated with this Release
return Promise.all( await Promise.all(
getters.releaseLinksToDelete.map((l) => getters.releaseLinksToDelete.map(({ id }) => deleteReleaseLinks({ state, id })),
api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
),
); );
})
.then(() => {
// Create a new link for each link in the form // Create a new link for each link in the form
return Promise.all( await Promise.all(
apiJson.assets.links.map((l) => getters.releaseLinksToCreate.map((link) => createReleaseLink({ state, link })),
api.createReleaseLink(state.projectId, state.release.tagName, l),
),
); );
})
.then(() => { dispatch('receiveSaveReleaseSuccess', state.release._links.self);
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease)); } catch (error) {
})
.catch((error) => {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details')); createFlash(s__('Release|Something went wrong while saving the release details.'));
}) }
);
}; };
...@@ -103,3 +103,39 @@ export const isValid = (_state, getters) => { ...@@ -103,3 +103,39 @@ export const isValid = (_state, getters) => {
const errors = getters.validationErrors; const errors = getters.validationErrors;
return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty; 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 { pick } from 'lodash';
import createGqClient, { fetchPolicies } from '~/lib/graphql'; import createGqClient, { fetchPolicies } from '~/lib/graphql';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility'; 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 }); export const gqClient = createGqClient({}, { fetchPolicy: fetchPolicies.NO_CACHE });
const convertScalarProperties = (graphQLRelease) => const convertScalarProperties = (graphQLRelease) =>
...@@ -125,8 +82,7 @@ const convertMilestones = (graphQLRelease) => ({ ...@@ -125,8 +82,7 @@ const convertMilestones = (graphQLRelease) => ({
/** /**
* Converts a single release object fetched from GraphQL * Converts a single release object fetched from GraphQL
* into a release object that matches the shape of the REST API * into a release object that matches the general structure of the REST API
* (the same shape that is returned by `apiJsonToRelease` above.)
* *
* @param graphQLRelease The release object returned from a GraphQL query * @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 "" ...@@ -26663,13 +26663,13 @@ msgstr ""
msgid "Releases|New Release" msgid "Releases|New Release"
msgstr "" msgstr ""
msgid "Release|Something went wrong while creating a new release" msgid "Release|Something went wrong while creating a new release."
msgstr "" msgstr ""
msgid "Release|Something went wrong while getting the release details." msgid "Release|Something went wrong while getting the release details."
msgstr "" msgstr ""
msgid "Release|Something went wrong while saving the release details" msgid "Release|Something went wrong while saving the release details."
msgstr "" msgstr ""
msgid "Remediations" msgid "Remediations"
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { getJSONFixture } from 'helpers/fixtures'; import { getJSONFixture } from 'helpers/fixtures';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash'; 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 { redirectTo } from '~/lib/utils/url_utility';
import { ASSET_LINK_TYPE } from '~/releases/constants'; 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 actions from '~/releases/stores/modules/edit_new/actions';
import * as types from '~/releases/stores/modules/edit_new/mutation_types'; import * as types from '~/releases/stores/modules/edit_new/mutation_types';
import createState from '~/releases/stores/modules/edit_new/state'; import createState from '~/releases/stores/modules/edit_new/state';
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -21,12 +19,21 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -21,12 +19,21 @@ jest.mock('~/lib/utils/url_utility', () => ({
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, 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', () => { describe('Release edit/new actions', () => {
let state; let state;
let release; let releaseResponse;
let mock;
let error; let error;
const setupState = (updates = {}) => { const setupState = (updates = {}) => {
...@@ -34,38 +41,26 @@ describe('Release edit/new actions', () => { ...@@ -34,38 +41,26 @@ describe('Release edit/new actions', () => {
isExistingRelease: true, isExistingRelease: true,
}; };
const rootState = {
featureFlags: {
graphqlIndividualReleasePage: false,
},
};
state = { state = {
...createState({ ...createState({
projectId: '18', projectId: '18',
tagName: release.tag_name, tagName: releaseResponse.tag_name,
releasesPagePath: 'path/to/releases/page', releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs', markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview', markdownPreviewPath: 'path/to/markdown/preview',
}), }),
...getters, ...getters,
...rootState,
...updates, ...updates,
}; };
}; };
beforeEach(() => { beforeEach(() => {
release = cloneDeep(originalRelease); releaseResponse = cloneDeep(originalOneReleaseForEditingQueryResponse);
mock = new MockAdapter(axios);
gon.api_version = 'v4'; gon.api_version = 'v4';
error = { message: 'An error occurred' }; error = new Error('Yikes!');
createFlash.mockClear(); createFlash.mockClear();
}); });
afterEach(() => {
mock.restore();
});
describe('when creating a new release', () => { describe('when creating a new release', () => {
beforeEach(() => { beforeEach(() => {
setupState({ isExistingRelease: false }); setupState({ isExistingRelease: false });
...@@ -118,15 +113,9 @@ describe('Release edit/new actions', () => { ...@@ -118,15 +113,9 @@ describe('Release edit/new actions', () => {
beforeEach(setupState); beforeEach(setupState);
describe('fetchRelease', () => { 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', () => { describe('when the network request to the Release API is successful', () => {
beforeEach(() => { 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`, () => { 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', () => { ...@@ -136,15 +125,15 @@ describe('Release edit/new actions', () => {
}, },
{ {
type: types.RECEIVE_RELEASE_SUCCESS, 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(() => { 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`, () => { 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', () => { ...@@ -282,44 +271,50 @@ describe('Release edit/new actions', () => {
describe('receiveSaveReleaseSuccess', () => { describe('receiveSaveReleaseSuccess', () => {
it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () =>
testAction(actions.receiveSaveReleaseSuccess, release, state, [ testAction(actions.receiveSaveReleaseSuccess, releaseResponse, state, [
{ type: types.RECEIVE_SAVE_RELEASE_SUCCESS }, { type: types.RECEIVE_SAVE_RELEASE_SUCCESS },
])); ]));
it("redirects to the release's dedicated page", () => { 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).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(release._links.self); expect(redirectTo).toHaveBeenCalledWith(selfUrl);
}); });
}); });
describe('createRelease', () => { describe('createRelease', () => {
let createReleaseUrl;
let releaseLinksToCreate; let releaseLinksToCreate;
beforeEach(() => { 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({ setupState({
release: camelCasedRelease, release,
releaseLinksToCreate, 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(() => { beforeEach(() => {
const expectedRelease = releaseToApiJson({ gqClient.mutate.mockResolvedValue({
...state.release, data: {
assets: { releaseCreate: {
links: releaseLinksToCreate, release: {
links: {
selfUrl,
},
},
errors: [],
},
}, },
}); });
mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release);
}); });
it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => { it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => {
...@@ -331,16 +326,16 @@ describe('Release edit/new actions', () => { ...@@ -331,16 +326,16 @@ describe('Release edit/new actions', () => {
[ [
{ {
type: 'receiveSaveReleaseSuccess', 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(() => { beforeEach(() => {
mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); gqClient.mutate.mockRejectedValue(error);
}); });
it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => { it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => {
...@@ -358,7 +353,7 @@ describe('Release edit/new actions', () => { ...@@ -358,7 +353,7 @@ describe('Release edit/new actions', () => {
.then(() => { .then(() => {
expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith( 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', () => { ...@@ -369,112 +364,209 @@ describe('Release edit/new actions', () => {
let getters; let getters;
let dispatch; let dispatch;
let commit; let commit;
let callOrder; let release;
beforeEach(() => { beforeEach(() => {
getters = { getters = {
releaseLinksToDelete: [{ id: '1' }, { id: '2' }], 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({ setupState({
release: convertObjectPropsToCamelCase(release), release,
...getters, ...getters,
}); });
dispatch = jest.fn(); dispatch = jest.fn();
commit = jest.fn(); commit = jest.fn();
callOrder = []; gqClient.mutate.mockResolvedValue({
jest.spyOn(api, 'updateRelease').mockImplementation(() => { data: {
callOrder.push('updateRelease'); releaseUpdate: {
return Promise.resolve({ data: release }); errors: [],
}); },
jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => { releaseAssetLinkDelete: {
callOrder.push('deleteReleaseLink'); errors: [],
return Promise.resolve(); },
}); releaseAssetLinkCreate: {
jest.spyOn(api, 'createReleaseLink').mockImplementation(() => { errors: [],
callOrder.push('createReleaseLink'); },
return Promise.resolve(); },
}); });
}); });
describe('when the network request to the Release API is successful', () => { describe('when the network request to the Release API is successful', () => {
it('dispatches receiveSaveReleaseSuccess', () => { it('dispatches receiveSaveReleaseSuccess', async () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { await actions.updateRelease({ commit, dispatch, state, getters });
expect(dispatch.mock.calls).toEqual([ expect(dispatch.mock.calls).toEqual([['receiveSaveReleaseSuccess', release._links.self]]);
['receiveSaveReleaseSuccess', apiJsonToRelease(release)],
]);
});
}); });
it('updates the Release, then deletes all existing links, and then recreates new links', () => { it('updates the Release, then deletes all existing links, and then recreates new links', async () => {
return actions.updateRelease({ dispatch, state, getters }).then(() => { await actions.updateRelease({ commit, dispatch, state, getters });
expect(callOrder).toEqual([
'updateRelease', // First, update the release
'deleteReleaseLink', expect(gqClient.mutate.mock.calls[0]).toEqual([
'deleteReleaseLink', {
'createReleaseLink', mutation: updateReleaseMutation,
'createReleaseLink', variables: getters.releaseUpdateMutatationVariables,
},
]); ]);
expect(api.updateRelease.mock.calls).toEqual([ // Then, delete the first asset link
[ expect(gqClient.mutate.mock.calls[1]).toEqual([
state.projectId, {
state.tagName, mutation: deleteReleaseAssetLinkMutation,
releaseToApiJson({ variables: { input: { id: getters.releaseLinksToDelete[0].id } },
...state.release,
assets: {
links: getters.releaseLinksToCreate,
}, },
}),
],
]); ]);
expect(api.deleteReleaseLink).toHaveBeenCalledTimes( // And the second
getters.releaseLinksToDelete.length, expect(gqClient.mutate.mock.calls[2]).toEqual([
); {
getters.releaseLinksToDelete.forEach((link) => { mutation: deleteReleaseAssetLinkMutation,
expect(api.deleteReleaseLink).toHaveBeenCalledWith( variables: { input: { id: getters.releaseLinksToDelete[1].id } },
state.projectId, },
state.tagName, ]);
link.id,
);
});
expect(api.createReleaseLink).toHaveBeenCalledTimes( // Recreate the first asset link
getters.releaseLinksToCreate.length, expect(gqClient.mutate.mock.calls[3]).toEqual([
); {
getters.releaseLinksToCreate.forEach((link) => { mutation: createReleaseAssetLinkMutation,
expect(api.createReleaseLink).toHaveBeenCalledWith( variables: {
state.projectId, input: {
state.tagName, projectPath: state.projectPath,
link, 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(() => { beforeEach(() => {
jest.spyOn(api, 'updateRelease').mockRejectedValue(error); gqClient.mutate.mockRejectedValue(error);
}); });
it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => { it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', async () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { await actions.updateRelease({ commit, dispatch, state, getters });
expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]); 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', () => { it('shows a flash message', async () => {
return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { await actions.updateRelease({ commit, dispatch, state, getters });
expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith( 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', () => { ...@@ -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 { cloneDeep } from 'lodash';
import { getJSONFixture } from 'helpers/fixtures'; import { getJSONFixture } from 'helpers/fixtures';
import { import {
releaseToApiJson,
apiJsonToRelease,
convertGraphQLRelease, convertGraphQLRelease,
convertAllReleasesGraphQLResponse, convertAllReleasesGraphQLResponse,
convertOneReleaseGraphQLResponse, convertOneReleaseGraphQLResponse,
...@@ -19,106 +17,6 @@ const originalOneReleaseForEditingQueryResponse = getJSONFixture( ...@@ -19,106 +17,6 @@ const originalOneReleaseForEditingQueryResponse = getJSONFixture(
); );
describe('releases/util.js', () => { 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', () => { describe('convertGraphQLRelease', () => {
let releaseFromResponse; let releaseFromResponse;
let convertedRelease; 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