Merge pull request #16207 from strapi/feature/review-workflow-entity-change-stage

feat(review-workflow): add route to update a stage on an entity
This commit is contained in:
Nathan Pichon 2023-03-27 17:49:08 +02:00 committed by GitHub
commit e956656c81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 174 additions and 27 deletions

View File

@ -25,9 +25,7 @@ export function InformationBoxEE() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { formatAPIError } = useAPIErrorHandler(); const { formatAPIError } = useAPIErrorHandler();
const { const { workflows: { data: [workflow] = [] } = {} } = useReviewWorkflows();
workflows: { data: workflow },
} = useReviewWorkflows(activeWorkflowStage?.id);
const { error, isLoading, mutateAsync } = useMutation(async ({ entityId, stageId, uid }) => { const { error, isLoading, mutateAsync } = useMutation(async ({ entityId, stageId, uid }) => {
const { const {

View File

@ -1,7 +1,12 @@
'use strict'; 'use strict';
const { ApplicationError } = require('@strapi/utils/lib/errors');
const { getService } = require('../../../utils'); const { getService } = require('../../../utils');
const { validateUpdateStages } = require('../../../validation/review-workflows'); const { hasReviewWorkflow } = require('../../../utils/review-workflows');
const {
validateUpdateStages,
validateUpdateStageOnEntity,
} = require('../../../validation/review-workflows');
module.exports = { module.exports = {
/** /**
@ -54,4 +59,37 @@ module.exports = {
ctx.body = { data }; ctx.body = { data };
}, },
/**
* Updates an entity's stage.
* @async
* @param {Object} ctx - The Koa context object.
* @param {Object} ctx.params - An object containing the parameters from the request URL.
* @param {string} ctx.params.model_uid - The model UID of the entity.
* @param {string} ctx.params.id - The ID of the entity to update.
* @param {Object} ctx.request.body.data - Optional data object containing the new stage ID for the entity.
* @param {string} ctx.request.body.data.id - The ID of the new stage for the entity.
* @throws {ApplicationError} If review workflows is not activated on the specified model UID.
* @throws {ValidationError} If the `data` object in the request body fails to pass validation.
* @returns {Promise<void>} A promise that resolves when the entity's stage has been updated.
*/
async updateEntity(ctx) {
const stagesService = getService('stages');
const { model_uid: modelUID, id: entityIdString } = ctx.params;
const entityId = Number(entityIdString);
const { id: stageId } = await validateUpdateStageOnEntity(
ctx.request?.body?.data,
'You should pass an id to the body of the put request.'
);
if (!hasReviewWorkflow({ strapi }, modelUID)) {
throw new ApplicationError(`Review workflows is not activated on ${modelUID}.`);
}
// TODO When multiple workflows are possible, check if the stage is part of the right one
// Didn't need this today as their can only be one workflow
ctx.body = await stagesService.updateEntity({ id: entityId, modelUID }, stageId);
},
}; };

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const { hasRWEnabled } = require('../utils/review-workflows'); const { hasReviewWorkflow } = require('../utils/review-workflows');
/** /**
* Remove all stage information for all content types that have had review workflows disabled * Remove all stage information for all content types that have had review workflows disabled
@ -16,7 +16,10 @@ const disableOnContentTypes = async ({ oldContentTypes, contentTypes }) => {
const oldContentType = oldContentTypes[uid]; const oldContentType = oldContentTypes[uid];
const contentType = contentTypes?.[uid]; const contentType = contentTypes?.[uid];
if (hasRWEnabled(oldContentType) && !hasRWEnabled(contentType)) { if (
hasReviewWorkflow({ strapi }, oldContentType) &&
!hasReviewWorkflow({ strapi }, contentType)
) {
// If review workflows has been turned off on a content type // If review workflows has been turned off on a content type
// remove stage information from all entities within this CT // remove stage information from all entities within this CT
uidsToRemove.push(uid); uidsToRemove.push(uid);

View File

@ -209,4 +209,21 @@ module.exports = [
], ],
}, },
}, },
{
method: 'PUT',
path: '/content-manager/(collection|single)-types/:model_uid/:id/stage',
handler: 'stages.updateEntity',
config: {
middlewares: [enableFeatureMiddleware('review-workflows')],
policies: [
'admin::isAuthenticatedAdmin',
{
name: 'admin::hasPermissions',
config: {
actions: ['admin::review-workflows.read'],
},
},
],
},
},
]; ];

View File

@ -2,7 +2,7 @@
const { isNil } = require('lodash/fp'); const { isNil } = require('lodash/fp');
const { ENTITY_STAGE_ATTRIBUTE } = require('../../constants/workflows'); const { ENTITY_STAGE_ATTRIBUTE } = require('../../constants/workflows');
const { hasRWEnabled, getDefaultWorkflow } = require('../../utils/review-workflows'); const { hasReviewWorkflow, getDefaultWorkflow } = require('../../utils/review-workflows');
/** /**
* Assigns the entity data to the default workflow stage if no stage is present in the data * Assigns the entity data to the default workflow stage if no stage is present in the data
@ -23,8 +23,7 @@ const getDataWithStage = async (data) => {
*/ */
const decorator = (service) => ({ const decorator = (service) => ({
async create(uid, opts = {}) { async create(uid, opts = {}) {
const model = strapi.getModel(uid); const hasRW = hasReviewWorkflow({ strapi }, uid);
const hasRW = hasRWEnabled(model);
if (!hasRW) { if (!hasRW) {
return service.create.call(this, uid, opts); return service.create.call(this, uid, opts);

View File

@ -120,7 +120,7 @@ function enableReviewWorkflow({ strapi }) {
[joinTable.joinColumn.name]: firstStage.id, [joinTable.joinColumn.name]: firstStage.id,
[typeColumn.name]: connection.raw('?', [contentTypeUID]), [typeColumn.name]: connection.raw('?', [contentTypeUID]),
}) })
.leftJoin(`${joinTable.name} AS jointable`, function () { .leftJoin(`${joinTable.name} AS jointable`, () => {
this.on('entity.id', '=', `jointable.${idColumn.name}`).andOn( this.on('entity.id', '=', `jointable.${idColumn.name}`).andOn(
`jointable.${typeColumn.name}`, `jointable.${typeColumn.name}`,
'=', '=',

View File

@ -5,7 +5,7 @@ const {
errors: { ApplicationError }, errors: { ApplicationError },
} = require('@strapi/utils'); } = require('@strapi/utils');
const { STAGE_MODEL_UID } = require('../../constants/workflows'); const { STAGE_MODEL_UID, ENTITY_STAGE_ATTRIBUTE } = require('../../constants/workflows');
const { getService } = require('../../utils'); const { getService } = require('../../utils');
module.exports = ({ strapi }) => { module.exports = ({ strapi }) => {
@ -69,6 +69,21 @@ module.exports = ({ strapi }) => {
}); });
}); });
}, },
/**
* Update the stage of an entity
*
* @param {object} entityInfo
* @param {number} entityInfo.id - Entity id
* @param {string} entityInfo.modelUID - the content-type of the entity
* @param {number} stageId - The id of the stage to assign to the entity
*/
updateEntity(entityInfo, stageId) {
return strapi.entityService.update(entityInfo.modelUID, entityInfo.id, {
data: { [ENTITY_STAGE_ATTRIBUTE]: stageId },
populate: [ENTITY_STAGE_ATTRIBUTE],
});
},
}; };
}; };

View File

@ -101,6 +101,20 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
await builder.cleanup(); await builder.cleanup();
}); });
beforeEach(async () => {
testWorkflow = await strapi.query(WORKFLOW_MODEL_UID).update({
where: { id: testWorkflow.id },
data: {
uid: 'workflow',
stages: [defaultStage.id, secondStage.id],
},
});
await updateContentType(productUID, {
components: [],
contentType: model,
});
});
describe('Get workflows', () => { describe('Get workflows', () => {
test("It shouldn't be available for public", async () => { test("It shouldn't be available for public", async () => {
const res = await requests.public.get('/admin/review-workflows/workflows'); const res = await requests.public.get('/admin/review-workflows/workflows');
@ -328,8 +342,6 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
}); });
describe('Enabling/Disabling review workflows on a content type', () => { describe('Enabling/Disabling review workflows on a content type', () => {
let response;
beforeAll(async () => { beforeAll(async () => {
await createEntry(productUID, { name: 'Product' }); await createEntry(productUID, { name: 'Product' });
await createEntry(productUID, { name: 'Product 1' }); await createEntry(productUID, { name: 'Product 1' });
@ -343,18 +355,18 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
}); });
await restart(); await restart();
response = await requests.admin({ const response = await requests.admin({
method: 'GET', method: 'GET',
url: `/content-type-builder/content-types/api::product.product`, url: `/content-type-builder/content-types/api::product.product`,
}); });
expect(response.body.data.schema.reviewWorkflows).toBeTruthy(); expect(response.body.data.schema.reviewWorkflows).toBeTruthy();
response = await getRWMorphTableResults(strapi.db.getConnection()); const morphTableResults = await getRWMorphTableResults(strapi.db.getConnection());
expect(response.length).toEqual(3); expect(morphTableResults.length).toEqual(3);
for (let i = 0; i < response.length; i += 1) { for (let i = 0; i < morphTableResults.length; i += 1) {
const entry = response[i]; const entry = morphTableResults[i];
expect(entry.related_id).toEqual(i + 1); expect(entry.related_id).toEqual(i + 1);
expect(entry.order).toEqual(1); expect(entry.order).toEqual(1);
} }
@ -368,14 +380,66 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
await restart(); await restart();
response = await requests.admin({ const response = await requests.admin({
method: 'GET', method: 'GET',
url: `/content-type-builder/content-types/api::product.product`, url: `/content-type-builder/content-types/api::product.product`,
}); });
expect(response.body.data.schema.reviewWorkflows).toBeFalsy(); expect(response.body.data.schema.reviewWorkflows).toBeFalsy();
response = await getRWMorphTableResults(strapi.db.getConnection()); const morphTableResults = await getRWMorphTableResults(strapi.db.getConnection());
expect(response.length).toEqual(0); expect(morphTableResults.length).toEqual(0);
});
});
describe('update a stage on an entity', () => {
describe('Review Workflow is enabled', () => {
beforeAll(async () => {
await updateContentType(productUID, {
components: [],
contentType: { ...model, reviewWorkflows: true },
});
await restart();
});
test('Should update the accordingly on an entity', async () => {
const entry = await createEntry(productUID, { name: 'Product' });
const response = await requests.admin({
method: 'PUT',
url: `/admin/content-manager/collection-types/${productUID}/${entry.id}/stage`,
body: {
data: { id: secondStage.id },
},
});
expect(response.status).toEqual(200);
expect(response.body[ENTITY_STAGE_ATTRIBUTE]).toEqual(
expect.objectContaining({ id: secondStage.id })
);
});
});
describe('Review Workflow is disabled', () => {
beforeAll(async () => {
await updateContentType(productUID, {
components: [],
contentType: { ...model, reviewWorkflows: false },
});
await restart();
});
test('Should not update the entity', async () => {
const entry = await createEntry(productUID, { name: 'Product' });
const response = await requests.admin({
method: 'PUT',
url: `/admin/content-manager/collection-types/${productUID}/${entry.id}/stage`,
body: {
data: { id: secondStage.id },
},
});
expect(response.status).toEqual(400);
expect(response.body.error).toBeDefined();
expect(response.body.error.name).toBe('ApplicationError');
});
}); });
}); });

View File

@ -3,17 +3,23 @@
const { WORKFLOW_MODEL_UID } = require('../constants/workflows'); const { WORKFLOW_MODEL_UID } = require('../constants/workflows');
/** /**
* Determine if a content type has the review workflows feature enabled * Checks if a content type has review workflows enabled.
* @param {Object} contentType * @param {string|Object} contentType - Either the modelUID of the content type, or the content type object.
* @returns * @returns {boolean} Whether review workflows are enabled for the specified content type.
*/ */
const hasRWEnabled = (contentType) => contentType?.options?.reviewWorkflows || false; function hasReviewWorkflow({ strapi }, contentType) {
if (typeof contentType === 'string') {
// If the input is a string, assume it's the modelUID of the content type and retrieve the corresponding object.
return hasReviewWorkflow({ strapi }, strapi.getModel(contentType));
}
// Otherwise, assume it's the content type object itself and return its `reviewWorkflows` option if it exists.
return contentType?.options?.reviewWorkflows || false;
}
// TODO To be refactored when multiple workflows are added // TODO To be refactored when multiple workflows are added
const getDefaultWorkflow = async ({ strapi }) => const getDefaultWorkflow = async ({ strapi }) =>
strapi.query(WORKFLOW_MODEL_UID).findOne({ populate: ['stages'] }); strapi.query(WORKFLOW_MODEL_UID).findOne({ populate: ['stages'] });
module.exports = { module.exports = {
hasRWEnabled, hasReviewWorkflow,
getDefaultWorkflow, getDefaultWorkflow,
}; };

View File

@ -8,10 +8,17 @@ const stageObject = yup.object().shape({
}); });
const validateUpdateStagesSchema = yup.array().of(stageObject).required(); const validateUpdateStagesSchema = yup.array().of(stageObject).required();
const validateUpdateStageOnEntity = yup
.object()
.shape({
id: yup.number().integer().min(1).required(),
})
.required();
module.exports = { module.exports = {
validateUpdateStages: validateYupSchema(validateUpdateStagesSchema, { validateUpdateStages: validateYupSchema(validateUpdateStagesSchema, {
strict: false, strict: false,
stripUnknown: true, stripUnknown: true,
}), }),
validateUpdateStageOnEntity: validateYupSchema(validateUpdateStageOnEntity),
}; };