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 { 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,
try {
const fetchResponse = await gqClient.query({
query: oneReleaseForEditingQuery,
variables: {
fullPath: state.projectPath,
tagName: state.tagName,
},
})
.then((response) => {
const { data: release } = convertOneReleaseGraphQLResponse(response);
});
const { data: release } = convertOneReleaseGraphQLResponse(fetchResponse);
commit(types.RECEIVE_RELEASE_SUCCESS, release);
})
.catch((error) => {
} 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.'));
});
};
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,46 +90,94 @@ 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}`);
}
};
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
.createRelease(state.projectId, apiJson)
.then(({ data }) => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data));
})
.catch((error) => {
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'));
});
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;
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
.updateRelease(state.projectId, state.tagName, apiJson)
/**
* 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
* 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
* race conditions (in particular, the "URLs must be unique")
* constraint.
......@@ -153,35 +186,34 @@ export const updateRelease = ({ commit, dispatch, state, getters }) => {
* 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.
* 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
*/
.then(({ data }) => {
// Save this response since we need it later in the Promise chain
updatedRelease = data;
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
return Promise.all(
getters.releaseLinksToDelete.map((l) =>
api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
),
await Promise.all(
getters.releaseLinksToDelete.map(({ id }) => deleteReleaseLinks({ state, 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),
),
await Promise.all(
getters.releaseLinksToCreate.map((link) => createReleaseLink({ state, link })),
);
})
.then(() => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
})
.catch((error) => {
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'));
})
);
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"
......
......@@ -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