From 786995389693df287f4a4f75c03f6f89f224c8f5 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 25 Jul 2023 14:40:22 +0200 Subject: [PATCH 01/32] poc: add review workflow stage change action --- examples/getstarted/src/index.js | 18 +++++++++++++++++- .../admin/ee/server/config/admin-actions.js | 7 +++++++ .../admin/server/content-types/Permission.js | 6 ++++++ packages/core/admin/server/services/role.js | 2 +- .../admin/server/validation/action-provider.js | 2 +- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/examples/getstarted/src/index.js b/examples/getstarted/src/index.js index 5abfcc85cf..ef31f881ba 100644 --- a/examples/getstarted/src/index.js +++ b/examples/getstarted/src/index.js @@ -16,7 +16,23 @@ module.exports = { * This gives you an opportunity to set up your data model, * run jobs, or perform some special logic. */ - bootstrap({ strapi }) {}, + async bootstrap({ strapi }) { + const roleService = strapi.service(`admin::role`); + const permissionService = strapi.service(`admin::permission`); + + // roleService.assignPermissions('1', [ + // { + // uid: 'review-workflows.change-stage', + // displayName: 'Change stage', + // pluginName: 'admin', + // section: 'internal', + // actionParameters: { + // from: 1, + // to: 2, + // }, + // }, + // ]); + }, /** * An asynchronous destroy function that runs before diff --git a/packages/core/admin/ee/server/config/admin-actions.js b/packages/core/admin/ee/server/config/admin-actions.js index 91e373ad59..2b577792a9 100644 --- a/packages/core/admin/ee/server/config/admin-actions.js +++ b/packages/core/admin/ee/server/config/admin-actions.js @@ -62,5 +62,12 @@ module.exports = { category: 'review workflows', subCategory: 'options', }, + { + uid: 'review-workflows.change-stage', + displayName: 'Change stage', + pluginName: 'admin', + section: 'internal', + // category: 'internal', // Category is only used for settings + }, ], }; diff --git a/packages/core/admin/server/content-types/Permission.js b/packages/core/admin/server/content-types/Permission.js index aaebd94378..78dd6b9d77 100644 --- a/packages/core/admin/server/content-types/Permission.js +++ b/packages/core/admin/server/content-types/Permission.js @@ -29,6 +29,12 @@ module.exports = { configurable: false, required: true, }, + actionParameters: { + type: 'json', + configurable: false, + required: false, + default: {}, + }, subject: { type: 'string', minLength: 1, diff --git a/packages/core/admin/server/services/role.js b/packages/core/admin/server/services/role.js index 59742bb88b..b2b1c572c4 100644 --- a/packages/core/admin/server/services/role.js +++ b/packages/core/admin/server/services/role.js @@ -24,7 +24,7 @@ const ACTIONS = { const sanitizeRole = omit(['users', 'permissions']); -const COMPARABLE_FIELDS = ['conditions', 'properties', 'subject', 'action']; +const COMPARABLE_FIELDS = ['conditions', 'properties', 'subject', 'action', 'actionProperties']; const pickComparableFields = pick(COMPARABLE_FIELDS); const jsonClean = (data) => JSON.parse(JSON.stringify(data)); diff --git a/packages/core/admin/server/validation/action-provider.js b/packages/core/admin/server/validation/action-provider.js index 1bcf390bde..b1bb9bed78 100644 --- a/packages/core/admin/server/validation/action-provider.js +++ b/packages/core/admin/server/validation/action-provider.js @@ -17,7 +17,7 @@ const registerProviderActionSchema = yup (v) => `${v.path}: The id can only contain lowercase letters, dots and hyphens.` ) .required(), - section: yup.string().oneOf(['contentTypes', 'plugins', 'settings']).required(), + section: yup.string().oneOf(['contentTypes', 'plugins', 'settings', 'internal']).required(), pluginName: yup.mixed().when('section', { is: 'plugins', then: validators.isAPluginName.required(), From 8b9fc735648ad4d8228230011f8ecb8263bf1419 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Wed, 2 Aug 2023 11:21:14 +0200 Subject: [PATCH 02/32] poc: add review workflow stage change action --- .../src/domain/permission/index.ts | 6 +++ .../src/engine/abilities/casl-ability.ts | 38 +++++++++++++++++-- packages/core/permissions/src/engine/index.ts | 9 ++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/core/permissions/src/domain/permission/index.ts b/packages/core/permissions/src/domain/permission/index.ts index 3e95d249eb..7d60c27cf0 100644 --- a/packages/core/permissions/src/domain/permission/index.ts +++ b/packages/core/permissions/src/domain/permission/index.ts @@ -4,8 +4,14 @@ const PERMISSION_FIELDS = ['action', 'subject', 'properties', 'conditions'] as c const sanitizePermissionFields = _.pick(PERMISSION_FIELDS); +export interface ParametrizedAction { + name: string; + params: Record; +} + export interface Permission { action: string; + actionParameters?: Record; subject?: string | object | null; properties?: object; conditions?: string[]; diff --git a/packages/core/permissions/src/engine/abilities/casl-ability.ts b/packages/core/permissions/src/engine/abilities/casl-ability.ts index f4fa9a3d7e..bd63584f85 100644 --- a/packages/core/permissions/src/engine/abilities/casl-ability.ts +++ b/packages/core/permissions/src/engine/abilities/casl-ability.ts @@ -1,9 +1,16 @@ import * as sift from 'sift'; import { AbilityBuilder, Ability, Subject } from '@casl/ability'; import { pick, isNil, isObject } from 'lodash/fp'; +import qs from 'qs'; + + +export interface ParametrizedAction { + name: string; + params: Record; +} export interface PermissionRule { - action: string; + action: string | ParametrizedAction; subject?: Subject | null; properties?: { fields?: string[]; @@ -13,6 +20,7 @@ export interface PermissionRule { export interface CustomAbilityBuilder { can(permission: PermissionRule): ReturnType['can']>; + buildParametrizedAction: (parametrizedAction: ParametrizedAction) => string; build(): Ability; } @@ -37,6 +45,10 @@ const conditionsMatcher = (conditions: unknown) => { return sift.createQueryTester(conditions, { operations }); }; +const buildParametrizedAction = ({ name, params }: ParametrizedAction) => { + return `${name}?${qs.stringify(params)}`; +}; + /** * Casl Ability Builder. */ @@ -48,16 +60,36 @@ export const caslAbilityBuilder = (): CustomAbilityBuilder => { const { action, subject, properties = {}, condition } = permission; const { fields } = properties; + const caslAction = (typeof action === "string") ? action : buildParametrizedAction(action); + return can( - action, + caslAction, isNil(subject) ? 'all' : subject, fields, isObject(condition) ? condition : undefined ); }, + buildParametrizedAction({ name, params }: ParametrizedAction) { + return `${name}?${qs.stringify(params)}`; + }, + build() { - return build({ conditionsMatcher }); + const ability = build({ conditionsMatcher }); + + function decorateCan(originalCan: Ability['can']) { + return function (...args: Parameters) { + const [action, ...rest] = args; + const caslAction = (typeof action === "string") ? action : buildParametrizedAction(action); + + // Call the original `can` method + return originalCan.apply(ability, [caslAction, ...rest]); + }; + } + + ability.can = decorateCan(ability.can); + return ability; + }, ...rest, diff --git a/packages/core/permissions/src/engine/index.ts b/packages/core/permissions/src/engine/index.ts index 34e034cd73..40680228d4 100644 --- a/packages/core/permissions/src/engine/index.ts +++ b/packages/core/permissions/src/engine/index.ts @@ -1,4 +1,5 @@ import _ from 'lodash/fp'; +import qs from 'qs'; import { Ability } from '@casl/ability'; import { providerFactory } from '@strapi/utils'; @@ -93,7 +94,13 @@ const newEngine = (params: EngineParams): Engine => { await state.hooks['before-evaluate.permission'].call(createBeforeEvaluateContext(permission)); - const { action, subject, properties, conditions = [] } = permission; + const { action: actionName, subject, properties, conditions = [], actionParameters = {} } = permission; + + let action = actionName; + + if (Object.keys(actionParameters).length > 0) { + action = `${actionName}?${qs.stringify(actionParameters)}`; + } if (conditions.length === 0) { return register({ action, subject, properties }); From cc3c1ca8a1c07ae6aece935a72c0a93d0bf32648 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Wed, 2 Aug 2023 11:29:40 +0200 Subject: [PATCH 03/32] chore: get started bootstrap testing --- examples/getstarted/src/index.js | 35 +++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/examples/getstarted/src/index.js b/examples/getstarted/src/index.js index ef31f881ba..4329ac3747 100644 --- a/examples/getstarted/src/index.js +++ b/examples/getstarted/src/index.js @@ -20,18 +20,29 @@ module.exports = { const roleService = strapi.service(`admin::role`); const permissionService = strapi.service(`admin::permission`); - // roleService.assignPermissions('1', [ - // { - // uid: 'review-workflows.change-stage', - // displayName: 'Change stage', - // pluginName: 'admin', - // section: 'internal', - // actionParameters: { - // from: 1, - // to: 2, - // }, - // }, - // ]); + // TODO: Remove - only for testing + await roleService.assignPermissions(2, [ + { + action: 'admin::review-workflows.change-stage', + actionParameters: { from: 1, to: 2 }, + }, + ]); + + + const user = await strapi + .query('admin::user') + .findOne({ where: { id: 2 }, populate: ['roles'] }); + + if (!user || !(user.isActive === true)) { + return { authenticated: false }; + } + + const userAbility = await permissionService.engine.generateUserAbility(user); + + console.log(userAbility.can({ + name: 'admin::review-workflows.change-stage', + params: { from: 1, to: 2 } + }, 'all')); }, /** From ad8f2af3377eeb29f8ef4c4b618f1e15119e7483 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Wed, 2 Aug 2023 15:19:22 +0200 Subject: [PATCH 04/32] feat: permission to domain when adding role permissions --- packages/core/admin/server/services/role.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/admin/server/services/role.js b/packages/core/admin/server/services/role.js index b2b1c572c4..a749f4ca5c 100644 --- a/packages/core/admin/server/services/role.js +++ b/packages/core/admin/server/services/role.js @@ -24,7 +24,7 @@ const ACTIONS = { const sanitizeRole = omit(['users', 'permissions']); -const COMPARABLE_FIELDS = ['conditions', 'properties', 'subject', 'action', 'actionProperties']; +const COMPARABLE_FIELDS = ['conditions', 'properties', 'subject', 'action', 'actionParameters']; const pickComparableFields = pick(COMPARABLE_FIELDS); const jsonClean = (data) => JSON.parse(JSON.stringify(data)); @@ -367,17 +367,20 @@ const assignPermissions = async (roleId, permissions = []) => { return permissionsToReturn; }; + const addPermissions = async (roleId, permissions) => { const { conditionProvider, createMany } = getService('permission'); const { sanitizeConditions } = permissionDomain; const permissionsWithRole = permissions .map(set('role', roleId)) - .map(sanitizeConditions(conditionProvider)); + .map(sanitizeConditions(conditionProvider)) + .map(permissionDomain.create); return createMany(permissionsWithRole); }; + const isContentTypeAction = (action) => action.section === CONTENT_TYPE_SECTION; /** From f2cea6be57cd47b50e1c1368b3126294e18cfe34 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Wed, 2 Aug 2023 15:19:47 +0200 Subject: [PATCH 05/32] feat: actionParameters default value --- .../core/admin/server/domain/permission/index.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/admin/server/domain/permission/index.js b/packages/core/admin/server/domain/permission/index.js index 942b62b097..d6da7007ee 100644 --- a/packages/core/admin/server/domain/permission/index.js +++ b/packages/core/admin/server/domain/permission/index.js @@ -26,8 +26,16 @@ const { * @property {string} subject - The subject on which the permission should applies */ -const permissionFields = ['id', 'action', 'subject', 'properties', 'conditions', 'role']; -const sanitizedPermissionFields = ['id', 'action', 'subject', 'properties', 'conditions']; +const permissionFields = [ + 'id', + 'action', + 'actionParameters', + 'subject', + 'properties', + 'conditions', + 'role', +]; +const sanitizedPermissionFields = ['id', 'action', 'actionParameters', 'subject', 'properties', 'conditions']; const sanitizePermissionFields = pick(sanitizedPermissionFields); @@ -36,6 +44,7 @@ const sanitizePermissionFields = pick(sanitizedPermissionFields); * @return {Permission} */ const getDefaultPermission = () => ({ + actionParameters: {}, conditions: [], properties: {}, subject: null, From eb78a7f39050597b65f12d97b6a6740decc9b113 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Fri, 4 Aug 2023 14:55:11 +0200 Subject: [PATCH 06/32] fix: update stage action to be stage.transition --- packages/core/admin/ee/server/config/admin-actions.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/admin/ee/server/config/admin-actions.js b/packages/core/admin/ee/server/config/admin-actions.js index 0223ef1d95..982d6ed28e 100644 --- a/packages/core/admin/ee/server/config/admin-actions.js +++ b/packages/core/admin/ee/server/config/admin-actions.js @@ -63,11 +63,10 @@ module.exports = { subCategory: 'options', }, { - uid: 'review-workflows.change-stage', + uid: 'review-workflows.stage.transition', displayName: 'Change stage', pluginName: 'admin', section: 'internal', - // category: 'internal', // Category is only used for settings }, ], }; From 5f8ee9e3a976bb27c18b2d1ec15248e0dd6afb8b Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Fri, 4 Aug 2023 14:55:35 +0200 Subject: [PATCH 07/32] feat: populate stage permissions by default --- .../core/admin/ee/server/constants/workflows.js | 12 ++++++++++++ .../ee/server/content-types/workflow-stage/index.js | 6 ++++++ .../admin/ee/server/controllers/workflows/index.js | 13 ++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/core/admin/ee/server/constants/workflows.js b/packages/core/admin/ee/server/constants/workflows.js index 07a79c0090..733a97a0e3 100644 --- a/packages/core/admin/ee/server/constants/workflows.js +++ b/packages/core/admin/ee/server/constants/workflows.js @@ -16,4 +16,16 @@ module.exports = { 'You’ve reached the limit of stages for this workflow in your plan. Try deleting some stages or contact Sales to enable more stages.', DUPLICATED_STAGE_NAME: 'Stage names must be unique.', }, + WORKFLOW_POPULATE: { + stages: { + populate: { + permissions: { + fields: ["action", "actionParameters"], + populate: { + role: { fields: ["id", "name"] }, + } + } + } + } + }, }; diff --git a/packages/core/admin/ee/server/content-types/workflow-stage/index.js b/packages/core/admin/ee/server/content-types/workflow-stage/index.js index 017103c65a..05076bd75b 100644 --- a/packages/core/admin/ee/server/content-types/workflow-stage/index.js +++ b/packages/core/admin/ee/server/content-types/workflow-stage/index.js @@ -40,6 +40,12 @@ module.exports = { inversedBy: 'stages', configurable: false, }, + permissions: { + type: 'relation', + target: 'admin::permission', + relation: 'manyToMany', + configurable: false, + } }, }, }; diff --git a/packages/core/admin/ee/server/controllers/workflows/index.js b/packages/core/admin/ee/server/controllers/workflows/index.js index 9fd8468c28..47d651f422 100644 --- a/packages/core/admin/ee/server/controllers/workflows/index.js +++ b/packages/core/admin/ee/server/controllers/workflows/index.js @@ -7,7 +7,7 @@ const { validateWorkflowCreate, validateWorkflowUpdate, } = require('../../validation/review-workflows'); -const { WORKFLOW_MODEL_UID } = require('../../constants/workflows'); +const { WORKFLOW_MODEL_UID, WORKFLOW_POPULATE } = require('../../constants/workflows'); /** * @@ -61,22 +61,25 @@ module.exports = { ctx.state.userAbility ); const { populate } = await sanitizedQuery.update(query); - const workflowBody = await validateWorkflowUpdate(body.data); - const workflow = await workflowService.findById(id, { populate: ['stages'] }); + // Find if workflow exists + const workflow = await workflowService.findById(id, { populate: WORKFLOW_POPULATE }); if (!workflow) { return ctx.notFound(); } - const getPermittedFieldToUpdate = sanitizeUpdateInput(workflow); + // Sanitize input data + const getPermittedFieldToUpdate = sanitizeUpdateInput(workflow); const dataToUpdate = await getPermittedFieldToUpdate(workflowBody); + // Update workflow const updatedWorkflow = await workflowService.update(workflow, { data: dataToUpdate, populate, }); + // Send sanitized response ctx.body = { data: await sanitizeOutput(updatedWorkflow), }; @@ -96,7 +99,7 @@ module.exports = { ); const { populate } = await sanitizedQuery.delete(query); - const workflow = await workflowService.findById(id, { populate: ['stages'] }); + const workflow = await workflowService.findById(id, { populate: WORKFLOW_POPULATE }); if (!workflow) { return ctx.notFound("Workflow doesn't exist"); } From d6ecb6386ac2423655eb5f6d0256818f9e0c29e2 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Fri, 4 Aug 2023 14:56:37 +0200 Subject: [PATCH 08/32] feat: add stage transitions service --- .../controllers/workflows/stages/index.js | 30 +++++-- .../review-workflows/stage-permissions.js | 60 ++++++++++++++ .../services/review-workflows/stages.js | 79 ++++++++++++++++--- .../review-workflows/workflows/index.js | 25 ++++-- .../ee/server/validation/review-workflows.js | 9 +++ 5 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 packages/core/admin/ee/server/services/review-workflows/stage-permissions.js diff --git a/packages/core/admin/ee/server/controllers/workflows/stages/index.js b/packages/core/admin/ee/server/controllers/workflows/stages/index.js index 983fc1b166..c2913a6086 100644 --- a/packages/core/admin/ee/server/controllers/workflows/stages/index.js +++ b/packages/core/admin/ee/server/controllers/workflows/stages/index.js @@ -3,7 +3,7 @@ const { mapAsync } = require('@strapi/utils'); const { getService } = require('../../../utils'); const { validateUpdateStageOnEntity } = require('../../../validation/review-workflows'); -const { STAGE_MODEL_UID } = require('../../../constants/workflows'); +const { STAGE_MODEL_UID, ENTITY_STAGE_ATTRIBUTE } = require('../../../constants/workflows'); /** * @@ -75,18 +75,36 @@ module.exports = { */ async updateEntity(ctx) { const stagesService = getService('stages'); + const stagePermissions = getService('stage-permissions'); const workflowService = getService('workflows'); - const { model_uid: modelUID, id: entityIdString } = ctx.params; + const { model_uid: modelUID, id } = ctx.params; const { body } = ctx.request; - const entityId = Number(entityIdString); - const { sanitizeOutput } = strapi .plugin('content-manager') .service('permission-checker') .create({ userAbility: ctx.state.userAbility, model: modelUID }); + // Load entity + const entity = await strapi.entityService.findOne(modelUID, Number(id), { + populate: [ENTITY_STAGE_ATTRIBUTE], + }); + + if (!entity) { + ctx.throw(404, 'Entity not found'); + } + + // Validate if entity stage can be updated + const canTransition = stagePermissions.can( + 'admin::review-workflows.stage.transition', + entity[ENTITY_STAGE_ATTRIBUTE]?.id + ); + + if (!canTransition) { + ctx.throw(403, 'Forbidden stage transition'); + } + const { id: stageId } = await validateUpdateStageOnEntity( { id: Number(body?.data?.id) }, 'You should pass an id to the body of the put request.' @@ -95,8 +113,8 @@ module.exports = { const workflow = await workflowService.assertContentTypeBelongsToWorkflow(modelUID); workflowService.assertStageBelongsToWorkflow(stageId, workflow); - const entity = await stagesService.updateEntity({ id: entityId, modelUID }, stageId); + const updatedEntity = await stagesService.updateEntity({ id: entity.id, modelUID }, stageId); - ctx.body = { data: await sanitizeOutput(entity) }; + ctx.body = { data: await sanitizeOutput(updatedEntity) }; }, }; diff --git a/packages/core/admin/ee/server/services/review-workflows/stage-permissions.js b/packages/core/admin/ee/server/services/review-workflows/stage-permissions.js new file mode 100644 index 0000000000..bfef894733 --- /dev/null +++ b/packages/core/admin/ee/server/services/review-workflows/stage-permissions.js @@ -0,0 +1,60 @@ +'use strict'; + +const { prop } = require('lodash/fp'); +const { + mapAsync, + errors: { ApplicationError }, +} = require('@strapi/utils'); +const { getService } = require('../../utils'); + +// TODO: This should use constants +const validActions = ['admin::review-workflows.stage.transition']; + +module.exports = ({ strapi }) => { + const roleService = getService('role'); + const permissionService = getService('permission'); + + return { + async register(roleId, action, fromStage) { + if (!validActions.includes(action)) { + throw new ApplicationError(`Invalid action ${action}`); + } + const permissions = await roleService.addPermissions(roleId, [ + { + action, + actionParameters: { + from: fromStage, + }, + }, + ]); + + // TODO: Filter response + return permissions; + }, + async registerMany(permissions) { + return mapAsync(permissions, this.register); + }, + async unregister(permissions) { + const permissionIds = permissions.map(prop('id')); + await permissionService.deleteByIds(permissionIds); + }, + can(action, fromStage) { + const requestState = strapi.requestContext.get()?.state; + + if (!requestState) { + return false; + } + + // Override permissions for super admin + const userRoles = requestState.user?.roles; + if (userRoles?.some((role) => role.code === 'strapi-super-admin')) { + return true; + } + + return requestState.userAbility.can({ + name: action, + params: { from: fromStage }, + }); + }, + }; +}; diff --git a/packages/core/admin/ee/server/services/review-workflows/stages.js b/packages/core/admin/ee/server/services/review-workflows/stages.js index 2004a16407..1d406ba897 100644 --- a/packages/core/admin/ee/server/services/review-workflows/stages.js +++ b/packages/core/admin/ee/server/services/review-workflows/stages.js @@ -2,15 +2,20 @@ const { mapAsync, + reduceAsync, errors: { ApplicationError, ValidationError }, } = require('@strapi/utils'); -const { map } = require('lodash/fp'); +const { map, pick } = require('lodash/fp'); const { STAGE_MODEL_UID, ENTITY_STAGE_ATTRIBUTE, ERRORS } = require('../../constants/workflows'); const { getService } = require('../../utils'); +const sanitizedStageFields = ['id', 'name', 'workflow']; +const sanitizeStageFields = pick(sanitizedStageFields); + module.exports = ({ strapi }) => { const metrics = getService('review-workflows-metrics', { strapi }); + const stagePermissionsService = getService('stage-permissions', { strapi }); const workflowsValidationService = getService('review-workflows-validation', { strapi }); return { @@ -32,20 +37,65 @@ module.exports = ({ strapi }) => { async createMany(stagesList, { fields } = {}) { const params = { select: fields ?? '*' }; + // TODO: pick the fields from the stage const stages = await Promise.all( stagesList.map((stage) => - strapi.entityService.create(STAGE_MODEL_UID, { data: stage, ...params }) + strapi.entityService.create(STAGE_MODEL_UID, { + data: sanitizeStageFields(stage), + ...params, + }) ) ); + // Create stage permissions + await reduceAsync(stagesList)(async (_, stage, idx) => { + // Ignore stages without permissions + if (!stage.permissions || stage.permissions.length === 0) { + return; + } + + const stagePermissions = stage.permissions; + const stageId = stages[idx].id; + + const permissions = await mapAsync( + stagePermissions, + // Register each stage permission + (permission) => + stagePermissionsService.register(permission.role, permission.action, stageId) + ); + + // Update stage with the new permissions + await strapi.entityService.update(STAGE_MODEL_UID, stageId, { + data: { + permissions: permissions.flat().map((p) => p.id), + }, + }); + }, []); + metrics.sendDidCreateStage(); return stages; }, async update(stageId, stageData) { + let stagePermissions = []; + + // TODO: Do not delete permissions if they are not changed + // Delete old permissions + await this.deleteStagePermissions(stageId); + + if (stageData.permissions) { + const permissions = await mapAsync(stageData.permissions, (permission) => + stagePermissionsService.register(permission.role, permission.action, stageId) + ); + stagePermissions = permissions.flat().map((p) => p.id); + } + const stage = await strapi.entityService.update(STAGE_MODEL_UID, stageId, { - data: stageData, + data: { + ...stageData, + permissions: stagePermissions, + }, }); metrics.sendDidEditStage(); @@ -53,20 +103,31 @@ module.exports = ({ strapi }) => { return stage; }, - async delete(stageId) { - const stage = await strapi.entityService.delete(STAGE_MODEL_UID, stageId); + async delete(stage) { + // Unregister all permissions related to this stage id + await this.deleteStagePermissions([stage]); + + const deletedStage = await strapi.entityService.delete(STAGE_MODEL_UID, stage.id); metrics.sendDidDeleteStage(); - return stage; + return deletedStage; }, - async deleteMany(stagesId) { + async deleteMany(stages) { + await this.deleteStagePermissions(stages); + return strapi.entityService.deleteMany(STAGE_MODEL_UID, { - filters: { id: { $in: stagesId } }, + filters: { id: { $in: stages.map((s) => s.id) } }, }); }, + async deleteStagePermissions(stages) { + // TODO: Find another way to do this for when we use the "to" parameter. + const permissions = stages.map((s) => s.permissions).flat(); + await stagePermissionsService.unregister(permissions || []); + }, + count({ workflowId } = {}) { const opts = {}; @@ -114,7 +175,7 @@ module.exports = ({ strapi }) => { }); }); - return this.delete(stage.id); + return this.delete(stage); }); return destStages.map((stage) => ({ ...stage, id: stage.id ?? createdStagesIds.shift() })); diff --git a/packages/core/admin/ee/server/services/review-workflows/workflows/index.js b/packages/core/admin/ee/server/services/review-workflows/workflows/index.js index c3c321612b..c9ca8f15d7 100644 --- a/packages/core/admin/ee/server/services/review-workflows/workflows/index.js +++ b/packages/core/admin/ee/server/services/review-workflows/workflows/index.js @@ -2,7 +2,7 @@ const { set, isString, map, get } = require('lodash/fp'); const { ApplicationError } = require('@strapi/utils').errors; -const { WORKFLOW_MODEL_UID } = require('../../../constants/workflows'); +const { WORKFLOW_MODEL_UID, WORKFLOW_POPULATE } = require('../../../constants/workflows'); const { getService } = require('../../../utils'); const { getWorkflowContentTypeFilter } = require('../../../utils/review-workflows'); const workflowsContentTypesFactory = require('./content-types'); @@ -17,6 +17,16 @@ const processFilters = ({ strapi }, filters = {}) => { return processedFilters; }; +// TODO: How can we improve this? Maybe using traversePopulate? +const processPopulate = (populate) => { + // If it does not exist or it's not an object (like an array) return the default populate + if (!populate) { + return populate; + } + + return WORKFLOW_POPULATE; +}; + module.exports = ({ strapi }) => { const workflowsContentTypes = workflowsContentTypesFactory({ strapi }); const workflowsValidationService = getService('review-workflows-validation', { strapi }); @@ -31,7 +41,9 @@ module.exports = ({ strapi }) => { */ async find(opts = {}) { const filters = processFilters({ strapi }, opts.filters); - return strapi.entityService.findMany(WORKFLOW_MODEL_UID, { ...opts, filters }); + const populate = processPopulate(opts.populate); + + return strapi.entityService.findMany(WORKFLOW_MODEL_UID, { ...opts, filters, populate }); }, /** @@ -41,7 +53,8 @@ module.exports = ({ strapi }) => { * @returns {Promise} - Workflow object matching the requested ID. */ findById(id, opts) { - return strapi.entityService.findOne(WORKFLOW_MODEL_UID, id, opts); + const populate = processPopulate(opts.populate); + return strapi.entityService.findOne(WORKFLOW_MODEL_UID, id, { ...opts, populate }); }, /** @@ -51,7 +64,7 @@ module.exports = ({ strapi }) => { * @throws {ValidationError} - If the workflow has no stages. */ async create(opts) { - let createOpts = { ...opts, populate: { stages: true } }; + let createOpts = { ...opts, populate: WORKFLOW_POPULATE }; workflowsValidationService.validateWorkflowStages(opts.data.stages); await workflowsValidationService.validateWorkflowCount(1); @@ -87,7 +100,7 @@ module.exports = ({ strapi }) => { */ async update(workflow, opts) { const stageService = getService('stages', { strapi }); - let updateOpts = { ...opts, populate: { stages: true } }; + let updateOpts = { ...opts, populate: { ...WORKFLOW_POPULATE } }; let updatedStageIds; await workflowsValidationService.validateWorkflowCount(); @@ -104,7 +117,7 @@ module.exports = ({ strapi }) => { .replaceStages(workflow.stages, opts.data.stages, workflow.contentTypes) .then((stages) => stages.map((stage) => stage.id)); - updateOpts = set('data.stages', updatedStageIds, opts); + updateOpts = set('data.stages', updatedStageIds, updateOpts); } // Update (un)assigned Content Types diff --git a/packages/core/admin/ee/server/validation/review-workflows.js b/packages/core/admin/ee/server/validation/review-workflows.js index a7246df507..03109c7351 100644 --- a/packages/core/admin/ee/server/validation/review-workflows.js +++ b/packages/core/admin/ee/server/validation/review-workflows.js @@ -9,6 +9,15 @@ const stageObject = yup.object().shape({ id: yup.number().integer().min(1), name: yup.string().max(255).required(), color: yup.string().matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), // hex color + permission: yup.object().shape({ + role: yup.number().integer().min(1).required(), + action: yup.string().oneOf(['admin::review-workflow.stage.transition']).required(), + // TODO: Validate format + actionParameters: yup.object().shape({ + from: yup.number().integer().min(1).required(), + to: yup.number().integer().min(1), + }), + }), }); const validateUpdateStageOnEntity = yup From 1a066908bfe646b47138793865c6aa47956a67bc Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Fri, 4 Aug 2023 14:57:02 +0200 Subject: [PATCH 09/32] feat: add stage transition service import --- packages/core/admin/ee/server/services/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/admin/ee/server/services/index.js b/packages/core/admin/ee/server/services/index.js index d9f71e1b1b..71936b7c9e 100644 --- a/packages/core/admin/ee/server/services/index.js +++ b/packages/core/admin/ee/server/services/index.js @@ -8,6 +8,7 @@ module.exports = { 'seat-enforcement': require('./seat-enforcement'), workflows: require('./review-workflows/workflows'), stages: require('./review-workflows/stages'), + 'stage-permissions': require('./review-workflows/stage-permissions'), 'review-workflows': require('./review-workflows/review-workflows'), 'review-workflows-validation': require('./review-workflows/validation'), 'review-workflows-decorator': require('./review-workflows/entity-service-decorator'), From 07173a8e75c4551449b7275f042c9f8178ffdb53 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Fri, 4 Aug 2023 14:58:43 +0200 Subject: [PATCH 10/32] fix: remove unused qs import --- packages/core/permissions/src/engine/abilities/casl-ability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/permissions/src/engine/abilities/casl-ability.ts b/packages/core/permissions/src/engine/abilities/casl-ability.ts index bd63584f85..62e16e127d 100644 --- a/packages/core/permissions/src/engine/abilities/casl-ability.ts +++ b/packages/core/permissions/src/engine/abilities/casl-ability.ts @@ -1,7 +1,7 @@ import * as sift from 'sift'; +import qs from 'qs'; import { AbilityBuilder, Ability, Subject } from '@casl/ability'; import { pick, isNil, isObject } from 'lodash/fp'; -import qs from 'qs'; export interface ParametrizedAction { From 3c1b898f8cb0d170690c35997bf1480539f68c4e Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Fri, 4 Aug 2023 15:21:15 +0200 Subject: [PATCH 11/32] fix: add qs lib in core permissions --- packages/core/permissions/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core/permissions/package.json b/packages/core/permissions/package.json index 3776d49438..f34bdafa50 100644 --- a/packages/core/permissions/package.json +++ b/packages/core/permissions/package.json @@ -38,6 +38,7 @@ "@casl/ability": "5.4.4", "@strapi/utils": "4.12.0", "lodash": "4.17.21", + "qs": "6.11.1", "sift": "16.0.1" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 3d8bc60102..3f75169a0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7878,6 +7878,7 @@ __metadata: "@strapi/utils": 4.12.0 eslint-config-custom: 4.12.0 lodash: 4.17.21 + qs: 6.11.1 sift: 16.0.1 tsconfig: 4.12.0 languageName: unknown From 1ae2af1f659c7d0805db58d0a494de4bd49f18e6 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Mon, 7 Aug 2023 17:05:47 +0200 Subject: [PATCH 12/32] fix: send src stage when updating stages --- .../services/review-workflows/stages.js | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/core/admin/ee/server/services/review-workflows/stages.js b/packages/core/admin/ee/server/services/review-workflows/stages.js index 1d406ba897..894d9771fe 100644 --- a/packages/core/admin/ee/server/services/review-workflows/stages.js +++ b/packages/core/admin/ee/server/services/review-workflows/stages.js @@ -5,7 +5,7 @@ const { reduceAsync, errors: { ApplicationError, ValidationError }, } = require('@strapi/utils'); -const { map, pick } = require('lodash/fp'); +const { map, pick, isEqual } = require('lodash/fp'); const { STAGE_MODEL_UID, ENTITY_STAGE_ATTRIBUTE, ERRORS } = require('../../constants/workflows'); const { getService } = require('../../utils'); @@ -77,15 +77,16 @@ module.exports = ({ strapi }) => { return stages; }, - async update(stageId, stageData) { + async update(srcStage, destStage) { let stagePermissions = []; + const stageId = destStage.id; // TODO: Do not delete permissions if they are not changed // Delete old permissions - await this.deleteStagePermissions(stageId); + await this.deleteStagePermissions([srcStage]); - if (stageData.permissions) { - const permissions = await mapAsync(stageData.permissions, (permission) => + if (destStage.permissions) { + const permissions = await mapAsync(destStage.permissions, (permission) => stagePermissionsService.register(permission.role, permission.action, stageId) ); stagePermissions = permissions.flat().map((p) => p.id); @@ -93,7 +94,7 @@ module.exports = ({ strapi }) => { const stage = await strapi.entityService.update(STAGE_MODEL_UID, stageId, { data: { - ...stageData, + ...destStage, permissions: stagePermissions, }, }); @@ -152,7 +153,10 @@ module.exports = ({ strapi }) => { const createdStagesIds = map('id', createdStages); // Update the workflow stages - await mapAsync(updated, (stage) => this.update(stage.id, stage)); + await mapAsync(updated, (destStage) => { + const srcStage = srcStages.find((s) => s.id === destStage.id); + return this.update(srcStage, destStage); + }); // Delete the stages that are not in the new stages list await mapAsync(deleted, async (stage) => { @@ -302,12 +306,19 @@ module.exports = ({ strapi }) => { */ function getDiffBetweenStages(sourceStages, comparisonStages) { const result = comparisonStages.reduce( + // ... + (acc, stageToCompare) => { const srcStage = sourceStages.find((stage) => stage.id === stageToCompare.id); if (!srcStage) { acc.created.push(stageToCompare); - } else if (srcStage.name !== stageToCompare.name || srcStage.color !== stageToCompare.color) { + } else if ( + !isEqual( + pick(['name', 'color', 'permissions'], srcStage), + pick(['name', 'color', 'permissions'], stageToCompare) + ) + ) { acc.updated.push(stageToCompare); } return acc; From a5759e617ef110c03ab32421a5d761f5f530cbac Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Mon, 7 Aug 2023 17:05:56 +0200 Subject: [PATCH 13/32] test: update rw stage permissions --- ...ew-workflows-stage-permissions.test.api.js | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js diff --git a/api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js b/api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js new file mode 100644 index 0000000000..fc59e7c397 --- /dev/null +++ b/api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js @@ -0,0 +1,116 @@ +'use strict'; + +const { mapAsync } = require('@strapi/utils'); + +const { createStrapiInstance } = require('api-tests/strapi'); +const { createAuthRequest, createRequest } = require('api-tests/request'); +const { createTestBuilder } = require('api-tests/builder'); +const { describeOnCondition } = require('api-tests/utils'); + +const { + STAGE_MODEL_UID, + WORKFLOW_MODEL_UID, + ENTITY_STAGE_ATTRIBUTE, +} = require('../../../../packages/core/admin/ee/server/constants/workflows'); + +const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; + +const productUID = 'api::product.product'; +const model = { + draftAndPublish: true, + pluginOptions: {}, + singularName: 'product', + pluralName: 'products', + displayName: 'Product', + kind: 'collectionType', + attributes: { + name: { + type: 'string', + }, + }, + options: { + reviewWorkflows: true, + }, +}; + +describeOnCondition(edition === 'EE')('Review workflows', () => { + const builder = createTestBuilder(); + + let strapi; + let workflow; + let rq; + let roles; + + const createWorkflow = async (data) => { + const name = `workflow-${Math.random().toString(36)}`; + const req = await rq.post('/admin/review-workflows/workflows?populate=*', { + body: { data: { name, ...data } }, + }); + return req.body.data; + }; + + const updateWorkflow = async (id, data) => { + const req = await rq.put(`/admin/review-workflows/workflows/${id}?populate=stages`, { + body: { data }, + }); + + return req.body.data; + }; + + const deleteWorkflow = async (id) => { + return rq.delete(`/admin/review-workflows/workflows/${id}`); + }; + + const getWorkflow = async (id) => { + const { body } = await rq.get(`/admin/review-workflows/workflows/${id}?populate=*`); + return body.data; + }; + + beforeAll(async () => { + await builder.addContentTypes([model]).build(); + + strapi = await createStrapiInstance(); + rq = await createAuthRequest({ strapi }); + + workflow = await createWorkflow({ + name: 'test-workflow', + contentTypes: [productUID], + stages: [{ name: 'Stage 1' }, { name: 'Stage 2' }], + }); + + // Get default roles + const { body } = await rq.get('/admin/roles'); + roles = body.data; + }); + + afterAll(async () => { + await strapi.destroy(); + await builder.cleanup(); + }); + + describe('Assign workflow permissions', () => { + // Create stage with permissions + test('Update stage with new permissions', async () => { + workflow = await updateWorkflow(workflow.id, { + stages: [ + { + ...workflow.stages[0], + permissions: [ + { + action: 'admin::review-workflows.stage.transition', + role: roles[0].id, + }, + { + action: 'admin::review-workflows.stage.transition', + role: roles[1].id, + }, + ], + }, + workflow.stages[1], + ], + }); + + expect(workflow.stages[0].permissions).toHaveLength(2); + }); + }); +}); From f1a8bf0de643d4bbc8d8e9591165585973c17baa Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 8 Aug 2023 10:57:48 +0200 Subject: [PATCH 14/32] fix: review workflow stage permissions yup validation --- .../ee/server/validation/review-workflows.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/core/admin/ee/server/validation/review-workflows.js b/packages/core/admin/ee/server/validation/review-workflows.js index 03109c7351..bb7fb6bbfe 100644 --- a/packages/core/admin/ee/server/validation/review-workflows.js +++ b/packages/core/admin/ee/server/validation/review-workflows.js @@ -9,15 +9,16 @@ const stageObject = yup.object().shape({ id: yup.number().integer().min(1), name: yup.string().max(255).required(), color: yup.string().matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), // hex color - permission: yup.object().shape({ - role: yup.number().integer().min(1).required(), - action: yup.string().oneOf(['admin::review-workflow.stage.transition']).required(), - // TODO: Validate format - actionParameters: yup.object().shape({ - from: yup.number().integer().min(1).required(), - to: yup.number().integer().min(1), - }), - }), + permissions: yup.array().of( + yup.object().shape({ + role: yup.number().integer().min(1).required(), + action: yup.string().oneOf(['admin::review-workflows.stage.transition']).required(), + actionParameters: yup.object().shape({ + from: yup.number().integer().min(1).required(), + to: yup.number().integer().min(1), + }), + }) + ), }); const validateUpdateStageOnEntity = yup From 88066d9d4a93c7699ec90b3c096574426ae88e3e Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 8 Aug 2023 10:58:01 +0200 Subject: [PATCH 15/32] fix: delete workflow --- .../ee/server/services/review-workflows/workflows/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/admin/ee/server/services/review-workflows/workflows/index.js b/packages/core/admin/ee/server/services/review-workflows/workflows/index.js index c9ca8f15d7..bfd6f42d3f 100644 --- a/packages/core/admin/ee/server/services/review-workflows/workflows/index.js +++ b/packages/core/admin/ee/server/services/review-workflows/workflows/index.js @@ -154,7 +154,7 @@ module.exports = ({ strapi }) => { return strapi.db.transaction(async () => { // Delete stages - await stageService.deleteMany(workflow.stages.map((stage) => stage.id)); + await stageService.deleteMany(workflow.stages); // Unassign all content types, this will migrate the content types to null await workflowsContentTypes.migrate({ From ba09b1e3bd8ec3b37e0ef0d89a7c5221b66d9a60 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 8 Aug 2023 10:58:23 +0200 Subject: [PATCH 16/32] test: review workflow update workflow permissions --- ...ew-workflows-stage-permissions.test.api.js | 191 +++++++++++++++--- .../services/review-workflows/stages.js | 3 +- 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js b/api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js index fc59e7c397..c6e12729ac 100644 --- a/api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js +++ b/api-tests/core/admin/ee/review-workflows-stage-permissions.test.api.js @@ -12,6 +12,7 @@ const { WORKFLOW_MODEL_UID, ENTITY_STAGE_ATTRIBUTE, } = require('../../../../packages/core/admin/ee/server/constants/workflows'); +const { create } = require('lodash'); const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; @@ -33,6 +34,18 @@ const model = { }, }; +const baseWorkflow = { + contentTypes: [productUID], + stages: [{ name: 'Stage 1' }, { name: 'Stage 2' }], +}; + +const getStageTransitionPermissions = (roleIds) => { + return roleIds.map((roleId) => ({ + action: 'admin::review-workflows.stage.transition', + role: roleId, + })); +}; + describeOnCondition(edition === 'EE')('Review workflows', () => { const builder = createTestBuilder(); @@ -46,7 +59,12 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { const req = await rq.post('/admin/review-workflows/workflows?populate=*', { body: { data: { name, ...data } }, }); - return req.body.data; + + const status = req.statusCode; + const error = req.body.error; + const workflow = req.body.data; + + return { workflow, status, error }; }; const updateWorkflow = async (id, data) => { @@ -54,16 +72,21 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { body: { data }, }); - return req.body.data; + const status = req.statusCode; + const error = req.body.error; + const workflow = req.body.data; + + return { workflow, status, error }; }; const deleteWorkflow = async (id) => { - return rq.delete(`/admin/review-workflows/workflows/${id}`); - }; + const req = await rq.delete(`/admin/review-workflows/workflows/${id}`); - const getWorkflow = async (id) => { - const { body } = await rq.get(`/admin/review-workflows/workflows/${id}?populate=*`); - return body.data; + const status = req.statusCode; + const error = req.body.error; + const workflow = req.body.data; + + return { workflow, status, error }; }; beforeAll(async () => { @@ -72,11 +95,7 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { strapi = await createStrapiInstance(); rq = await createAuthRequest({ strapi }); - workflow = await createWorkflow({ - name: 'test-workflow', - contentTypes: [productUID], - stages: [{ name: 'Stage 1' }, { name: 'Stage 2' }], - }); + workflow = await createWorkflow(baseWorkflow); // Get default roles const { body } = await rq.get('/admin/roles'); @@ -90,27 +109,147 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { describe('Assign workflow permissions', () => { // Create stage with permissions - test('Update stage with new permissions', async () => { - workflow = await updateWorkflow(workflow.id, { + test('Can assign new stage permissions', async () => { + const { workflow } = await createWorkflow({ + ...baseWorkflow, stages: [ { - ...workflow.stages[0], - permissions: [ - { - action: 'admin::review-workflows.stage.transition', - role: roles[0].id, - }, - { - action: 'admin::review-workflows.stage.transition', - role: roles[1].id, - }, - ], + ...baseWorkflow.stages[0], + permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]), }, - workflow.stages[1], + baseWorkflow.stages[1], ], }); expect(workflow.stages[0].permissions).toHaveLength(2); }); + + // Can unassign a role + test('Can remove stage permissions', async () => { + // Create workflow with permissions to transition to role 0 and 1 + const { workflow } = await createWorkflow({ + ...baseWorkflow, + stages: [ + { + ...baseWorkflow.stages[0], + permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]), + }, + baseWorkflow.stages[1], + ], + }); + + // Update workflow to remove role 1 + const { workflow: updatedWorkflow } = await updateWorkflow(workflow.id, { + stages: [ + { + ...workflow.stages[0], + permissions: getStageTransitionPermissions([roles[0].id]), + }, + workflow.stages[1], + ], + }); + + // Validate that permissions have been removed from database + const deletedPermission = await strapi.query('admin::permission').findOne({ + where: { + id: workflow.stages[0].permissions[1].id, + }, + }); + + expect(updatedWorkflow.stages[0].permissions).toHaveLength(1); + expect(deletedPermission).toBeNull(); + }); + + test('Deleting stage removes permissions', async () => { + const { workflow } = await createWorkflow({ + ...baseWorkflow, + stages: [ + { + ...baseWorkflow.stages[0], + permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]), + }, + baseWorkflow.stages[1], + ], + }); + + const { workflow: updatedWorkflow } = await updateWorkflow(workflow.id, { + ...workflow, + stages: [workflow.stages[1]], + }); + + // Deleted stage permissions should be removed from database + const permissions = await strapi.query('admin::permission').findMany({ + where: { + id: { $in: workflow.stages[0].permissions.map((p) => p.id) }, + }, + }); + + expect(permissions).toHaveLength(0); + }); + + test('Deleting workflow removes permissions', async () => { + const { workflow } = await createWorkflow({ + ...baseWorkflow, + stages: [ + { + ...baseWorkflow.stages[0], + permissions: getStageTransitionPermissions([roles[0].id, roles[1].id]), + }, + ], + }); + + await deleteWorkflow(workflow.id); + + // Deleted workflow permissions should be removed from database + const permissions = await strapi.query('admin::permission').findMany({ + where: { + id: { $in: workflow.stages[0].permissions.map((p) => p.id) }, + }, + }); + + expect(permissions).toHaveLength(0); + }); + + test('Fails when using invalid action', async () => { + const { status, error } = await createWorkflow({ + ...baseWorkflow, + stages: [ + { + ...baseWorkflow.stages[0], + permissions: [{ action: 'invalid-action', role: roles[0].id }], + }, + ], + }); + + expect(status).toBe(400); + expect(error.name).toBe('ValidationError'); + }); + + // TODO + test.skip('Can send permissions as undefined to apply partial update', async () => { + // Creates workflow with permissions + const { workflow } = await createWorkflow({ + ...baseWorkflow, + stages: [ + { + ...baseWorkflow.stages[0], + permissions: [{ action: 'invalid-action', role: roles[0].id }], + }, + ], + }); + + const { workflow: updatedWorkflow } = await updateWorkflow(workflow.id, { + ...workflow, + stages: [ + { + ...workflow.stages[0], + permissions: undefined, + }, + ], + }); + + // Permissions should be kept + expect(updatedWorkflow.stages[0].permissions).toHaveLength(1); + }); }); }); diff --git a/packages/core/admin/ee/server/services/review-workflows/stages.js b/packages/core/admin/ee/server/services/review-workflows/stages.js index 894d9771fe..fd6f7fa089 100644 --- a/packages/core/admin/ee/server/services/review-workflows/stages.js +++ b/packages/core/admin/ee/server/services/review-workflows/stages.js @@ -82,7 +82,6 @@ module.exports = ({ strapi }) => { const stageId = destStage.id; // TODO: Do not delete permissions if they are not changed - // Delete old permissions await this.deleteStagePermissions([srcStage]); if (destStage.permissions) { @@ -125,7 +124,7 @@ module.exports = ({ strapi }) => { async deleteStagePermissions(stages) { // TODO: Find another way to do this for when we use the "to" parameter. - const permissions = stages.map((s) => s.permissions).flat(); + const permissions = stages.map((s) => s.permissions || []).flat(); await stagePermissionsService.unregister(permissions || []); }, From e46553a31916c94d760dc535495ceb7a1a7b9229 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 1 Aug 2023 14:28:44 +0200 Subject: [PATCH 17/32] Enhancement: Render permissions for each workflow stage --- .../admin/src/hooks/useAdminRoles/index.js | 2 +- .../pages/ReviewWorkflows/actions/index.js | 39 ++- .../actions/tests/index.test.js | 79 ++++++- .../components/Stages/Stage/Stage.js | 108 ++++++++- .../Stages/Stage/tests/Stage.test.js | 214 ++++++++++++++--- .../components/Stages/tests/Stages.test.js | 88 +++---- .../WorkflowAttributes/WorkflowAttributes.js | 31 +-- .../tests/WorkflowAttributes.test.js | 198 ++++++++-------- .../pages/ReviewWorkflows/constants.js | 4 + .../pages/CreateView/CreateView.js | 66 ++++-- .../pages/EditView/EditView.js | 115 ++++++--- .../pages/ReviewWorkflows/reducer/index.js | 60 +++-- .../reducer/tests/index.test.js | 222 +++++------------- .../pages/ReviewWorkflows/selectors.js | 45 ++++ .../utils/tests/validateWorkflow.test.js | 75 ++++++ .../ReviewWorkflows/utils/validateWorkflow.js | 27 +++ 16 files changed, 906 insertions(+), 467 deletions(-) create mode 100644 packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index 86ffcda4dc..a0ea4520aa 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -31,7 +31,7 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { } return { - roles: roles.sort((a, b) => formatter.compare(a.name, b.name)), + roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), error, isError, isLoading, diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js index 9ebb777b13..5711ee9924 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js @@ -2,19 +2,27 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, + ACTION_SET_WORKFLOWS, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, ACTION_UPDATE_WORKFLOW, } from '../constants'; -export function setWorkflow({ status, data }) { +export function setWorkflow({ workflow }) { return { type: ACTION_SET_WORKFLOW, - payload: { - status, - workflow: data, - }, + payload: workflow, + }; +} + +export function setWorkflows({ workflows }) { + return { + type: ACTION_SET_WORKFLOWS, + payload: workflows, }; } @@ -66,3 +74,24 @@ export function resetWorkflow() { type: ACTION_RESET_WORKFLOW, }; } + +export function setContentTypes(payload) { + return { + type: ACTION_SET_CONTENT_TYPES, + payload, + }; +} + +export function setRoles(payload) { + return { + type: ACTION_SET_ROLES, + payload, + }; +} + +export function setIsLoading(isLoading) { + return { + type: ACTION_SET_IS_LOADING, + payload: isLoading, + }; +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js index 0cb1ac3177..45e0e2699e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js @@ -1,19 +1,42 @@ -import { addStage, deleteStage, setWorkflow, updateStage } from '..'; +import { + addStage, + deleteStage, + setWorkflow, + setWorkflows, + updateStage, + updateStagePosition, + updateWorkflow, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, +} from '..'; import { ACTION_SET_WORKFLOW, ACTION_DELETE_STAGE, ACTION_ADD_STAGE, ACTION_UPDATE_STAGE, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, + ACTION_SET_WORKFLOWS, + ACTION_UPDATE_STAGE_POSITION, + ACTION_RESET_WORKFLOW, + ACTION_UPDATE_WORKFLOW, } from '../../constants'; describe('Admin | Settings | Review Workflow | actions', () => { test('setWorkflow()', () => { - expect(setWorkflow({ status: 'loading', data: null, something: 'else' })).toStrictEqual({ + expect(setWorkflow({ workflow: null, something: 'else' })).toStrictEqual({ type: ACTION_SET_WORKFLOW, - payload: { - status: 'loading', - workflow: null, - }, + payload: null, + }); + }); + + test('setWorkflows()', () => { + expect(setWorkflows({ workflows: [] })).toStrictEqual({ + type: ACTION_SET_WORKFLOWS, + payload: [], }); }); @@ -49,4 +72,48 @@ describe('Admin | Settings | Review Workflow | actions', () => { }, }); }); + + test('updateStagePosition()', () => { + expect(updateStagePosition(1, 2)).toStrictEqual({ + type: ACTION_UPDATE_STAGE_POSITION, + payload: { + newIndex: 2, + oldIndex: 1, + }, + }); + }); + + test('updateWorkflow()', () => { + expect(updateWorkflow({})).toStrictEqual({ + type: ACTION_UPDATE_WORKFLOW, + payload: {}, + }); + }); + + test('resetWorkflow()', () => { + expect(resetWorkflow()).toStrictEqual({ + type: ACTION_RESET_WORKFLOW, + }); + }); + + test('setContentTypes()', () => { + expect(setContentTypes({})).toStrictEqual({ + type: ACTION_SET_CONTENT_TYPES, + payload: {}, + }); + }); + + test('setRoles()', () => { + expect(setRoles({})).toStrictEqual({ + type: ACTION_SET_ROLES, + payload: {}, + }); + }); + + test('setIsLoading()', () => { + expect(setIsLoading(true)).toStrictEqual({ + type: ACTION_SET_IS_LOADING, + payload: true, + }); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js index efd000bc14..212731c713 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js @@ -9,25 +9,34 @@ import { Grid, GridItem, IconButton, + MultiSelect, + MultiSelectGroup, + MultiSelectOption, SingleSelect, SingleSelectOption, TextInput, VisuallyHidden, } from '@strapi/design-system'; -import { useTracking } from '@strapi/helper-plugin'; +import { NotAllowedInput, useTracking } from '@strapi/helper-plugin'; import { Drag, Trash } from '@strapi/icons'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; import { useDragAndDrop } from '../../../../../../../../../admin/src/content-manager/hooks'; import { composeRefs } from '../../../../../../../../../admin/src/content-manager/utils'; import { deleteStage, updateStage, updateStagePosition } from '../../../actions'; import { DRAG_DROP_TYPES } from '../../../constants'; +import { selectRoles } from '../../../selectors'; import { getAvailableStageColors, getStageColorByHex } from '../../../utils/colors'; +const NestedOption = styled(MultiSelectOption)` + padding-left: ${({ theme }) => theme.spaces[7]}; +`; + const AVAILABLE_COLORS = getAvailableStageColors(); function StageDropPreview() { @@ -144,6 +153,10 @@ export function Stage({ const [isOpen, setIsOpen] = React.useState(isOpenDefault); const [nameField, nameMeta, nameHelper] = useField(`stages.${index}.name`); const [colorField, colorMeta, colorHelper] = useField(`stages.${index}.color`); + const [permissionsField, permissionsMeta, permissionsHelper] = useField( + `stages.${index}.permissions` + ); + const roles = useSelector(selectRoles); const [{ handlerId, isDragging, handleKeyDown }, stageRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(canReorder, { index, @@ -177,6 +190,11 @@ export function Stage({ const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; + const filteredRoles = roles + // Super admins always have permissions to do everything and therefore + // there is no point in removing permissions for the role + .filter((role) => role.code !== 'strapi-super-admin'); + return ( {liveText && {liveText}} @@ -315,6 +333,92 @@ export function Stage({ })} + + + {filteredRoles.length === 0 ? ( + + ) : ( + { + // Because the select components expects strings for values, but + // the yup schema validates numbers are sent to the API, we have + // to coerce the string value back to a number + const nextValues = values.map((value) => ({ + role: parseInt(value, 10), + action: 'admin::review-workflow.stage.transition', + })); + + permissionsHelper.setValue(nextValues); + + dispatch(updateStage(id, { permissions: nextValues })); + }} + placeholder={formatMessage({ + id: 'Settings.review-workflows.stage.permissions.placeholder', + defaultMessage: 'Select a role', + })} + required + // The Select component expects strings for values + value={(permissionsField.value ?? []).map((permission) => `${permission.role}`)} + withTags + > + {[ + { + value: null, + label: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.allRoles.label', + defaultMessage: 'All roles', + }), + children: filteredRoles.map((role) => ({ + value: `${role.id}`, + label: role.name, + })), + }, + ].map((role) => { + if ('children' in role) { + return ( + child.value)} + > + {role.children.map((role) => { + return ( + + {role.label} + + ); + })} + + ); + } + + return ( + + {role.label} + + ); + })} + + )} + diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js index f31a7a8290..885684b793 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js @@ -1,16 +1,16 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../../admin/src/core/store/configureStore'; -import { STAGE_COLOR_DEFAULT } from '../../../../constants'; +import { REDUX_NAMESPACE, STAGE_COLOR_DEFAULT } from '../../../../constants'; import { reducer } from '../../../../reducer'; import { Stage } from '../Stage'; @@ -19,18 +19,86 @@ const STAGES_FIXTURE = { index: 0, }; +const WORKFLOWS_FIXTURE = [ + { + id: 1, + name: 'Default', + contentTypes: ['uid1'], + stages: [], + }, + + { + id: 2, + name: 'Default 2', + contentTypes: ['uid2'], + stages: [], + }, +]; + +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], +}; + +const ROLES_FIXTURE = [ + { + id: 1, + code: 'strapi-editor', + name: 'Editor', + }, + + { + id: 2, + code: 'strapi-author', + name: 'Author', + }, + + { + id: 3, + code: 'strapi-super-admin', + name: 'Super Admin', + }, +]; + const ComponentFixture = ({ // eslint-disable-next-line react/prop-types stages = [ { color: STAGE_COLOR_DEFAULT, name: 'something', + permissions: [{ role: 1, action: 'admin::review-workflow.stage.transition' }], }, ], ...props }) => { - const store = configureStore([], [reducer]); - const formik = useFormik({ enableReinitialize: true, initialValues: { @@ -40,21 +108,43 @@ const ComponentFixture = ({ }); return ( - - - - - - - - - - - + + + ); }; -const setup = (props) => render(); +const setup = ({ roles, ...props } = {}) => + render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: CONTENT_TYPES_FIXTURE, + roles: roles || ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }); const user = userEvent.setup(); @@ -72,11 +162,25 @@ describe('Admin | Settings | Review Workflow | Stage', () => { // does not have better identifiers await user.click(container.querySelector('button[aria-expanded]')); - expect(queryByRole('textbox')).toBeInTheDocument(); - expect(getByRole('textbox').value).toBe('something'); + // Expect the accordion header to have the same value as the textbox + expect(getByRole('button', { name: /something/i })); expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name'); - expect(getByRole('combobox')).toHaveTextContent('Blue'); + // Name + expect(queryByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox').value).toBe('something'); + + // Color combobox + await waitFor(() => + expect(getByRole('combobox', { name: /color/i })).toHaveTextContent('Blue') + ); + + // Permissions combobox + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toHaveTextContent('Editor') + ); expect( queryByRole('button', { @@ -88,27 +192,31 @@ describe('Admin | Settings | Review Workflow | Stage', () => { it('should open the accordion panel if isOpen = true', async () => { const { queryByRole } = setup({ isOpen: true }); - expect(queryByRole('textbox')).toBeInTheDocument(); + await waitFor(() => expect(queryByRole('textbox')).toBeInTheDocument()); }); it('should not render the delete button if canDelete=false', async () => { const { queryByRole } = setup({ isOpen: true, canDelete: false }); - expect( - queryByRole('button', { - name: /delete stage/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /delete stage/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not render delete drag button if canUpdate=false', async () => { const { queryByRole } = setup({ isOpen: true, canUpdate: false }); - expect( - queryByRole('button', { - name: /drag/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /drag/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not crash on a custom color code', async () => { @@ -131,7 +239,49 @@ describe('Admin | Settings | Review Workflow | Stage', () => { await user.click(container.querySelector('button[aria-expanded]')); + // Name expect(getByRole('textbox')).toHaveAttribute('disabled'); - expect(getByRole('combobox')).toHaveAttribute('data-disabled'); + + // Color + expect(getByRole('combobox', { name: /color/i })).toHaveAttribute('data-disabled'); + + // Permissions + expect(getByRole('combobox', { name: /roles that can change this stage/i })).toHaveAttribute( + 'data-disabled' + ); + }); + + it('should render a list of all available roles (except super admins)', async () => { + const { container, getByRole, queryByRole } = setup({ canUpdate: true }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toBeInTheDocument() + ); + + await user.click(getByRole('combobox', { name: /roles that can change this stage/i })); + + await waitFor(() => expect(getByRole('option', { name: /All roles/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Editor/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Author/i })).toBeInTheDocument()); + await waitFor(() => + expect(queryByRole('option', { name: /Super Admin/i })).not.toBeInTheDocument() + ); + }); + + it('should render a no permissions fallback, if no roles are available', async () => { + const { container, getByText } = setup({ + canUpdate: true, + roles: [...ROLES_FIXTURE].filter((role) => role.code === 'strapi-super-admin'), + }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect(getByText(/you don’t have the permission to see roles/i)).toBeInTheDocument() + ); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js index 07f6286491..9877245eed 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js @@ -1,9 +1,8 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { fireEvent, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; @@ -11,7 +10,7 @@ import { Provider } from 'react-redux'; import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; import * as actions from '../../../actions'; -import { ACTION_SET_WORKFLOW, STAGE_COLOR_DEFAULT } from '../../../constants'; +import { STAGE_COLOR_DEFAULT } from '../../../constants'; import { reducer } from '../../../reducer'; import { Stages } from '../Stages'; @@ -21,6 +20,15 @@ jest.mock('../../../actions', () => ({ ...jest.requireActual('../../../actions'), })); +// A single stage needs a formik provider, which is a bit complicated to setup. +// Since we don't want to test the single stages, but the overall composition +// it is the easiest for the test setup to just render an id instead of the +// whole component. +jest.mock('../Stage', () => ({ + __esModule: true, + Stage: ({ id }) => id, +})); + const STAGES_FIXTURE = [ { id: 1, @@ -35,44 +43,25 @@ const STAGES_FIXTURE = [ }, ]; -const WORKFLOWS_FIXTURE = [ - { - id: 1, - stages: STAGES_FIXTURE, - }, -]; +const setup = (props) => ({ + ...render(, { + wrapper({ children }) { + const store = configureStore([], [reducer]); -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); - - store.dispatch({ type: ACTION_SET_WORKFLOW, payload: { workflows: WORKFLOWS_FIXTURE } }); - - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - stages: STAGES_FIXTURE, + return ( + + + + {children} + + + + ); }, - validateOnChange: false, - }); + }), - return ( - - - - - - - - - - - - ); -}; - -const setup = (props) => render(); - -const user = userEvent.setup(); + user: userEvent.setup(), +}); describe('Admin | Settings | Review Workflow | Stages', () => { beforeEach(() => { @@ -82,8 +71,8 @@ describe('Admin | Settings | Review Workflow | Stages', () => { it('should render a list of stages', () => { const { getByText } = setup(); - expect(getByText(STAGES_FIXTURE[0].name)).toBeInTheDocument(); - expect(getByText(STAGES_FIXTURE[1].name)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[0].id)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[1].id)).toBeInTheDocument(); }); it('should render a "add new stage" button', () => { @@ -93,7 +82,7 @@ describe('Admin | Settings | Review Workflow | Stages', () => { }); it('should append a new stage when clicking "add new stage"', async () => { - const { getByRole } = setup(); + const { getByRole, user } = setup(); const spy = jest.spyOn(actions, 'addStage'); await user.click( @@ -106,23 +95,6 @@ describe('Admin | Settings | Review Workflow | Stages', () => { expect(spy).toBeCalledWith({ name: '' }); }); - it('should update the name of a stage by changing the input value', async () => { - const { queryByRole, getByRole } = setup(); - const spy = jest.spyOn(actions, 'updateStage'); - - await user.click(getByRole('button', { name: /stage-2/i })); - - const input = queryByRole('textbox', { - name: /stage name/i, - }); - - fireEvent.change(input, { target: { value: 'New name' } }); - - expect(spy).toBeCalledWith(2, { - name: 'New name', - }); - }); - it('should not render the "add stage" button if canUpdate = false', () => { const { queryByText } = setup({ canUpdate: false }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js index 46f6c45f4e..af8f0e72c3 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js @@ -13,10 +13,11 @@ import { useCollator } from '@strapi/helper-plugin'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { updateWorkflow } from '../../actions'; +import { selectContentTypes, selectCurrentWorkflow, selectWorkflows } from '../../selectors'; const NestedOption = styled(MultiSelectOption)` padding-left: ${({ theme }) => theme.spaces[7]}; @@ -26,14 +27,12 @@ const ContentTypeTakeNotice = styled(Typography)` font-style: italic; `; -export function WorkflowAttributes({ - canUpdate, - contentTypes: { collectionTypes, singleTypes }, - currentWorkflow, - workflows, -}) { +export function WorkflowAttributes({ canUpdate }) { const { formatMessage, locale } = useIntl(); const dispatch = useDispatch(); + const { collectionTypes, singleTypes } = useSelector(selectContentTypes); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const workflows = useSelector(selectWorkflows); const [nameField, nameMeta, nameHelper] = useField('name'); const [contentTypesField, contentTypesMeta, contentTypesHelper] = useField('contentTypes'); const formatter = useCollator(locale, { @@ -97,7 +96,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.collectionTypes.label', defaultMessage: 'Collection Types', }), - children: collectionTypes + children: [...collectionTypes] .sort((a, b) => formatter.compare(a.info.displayName, b.info.displayName)) .map((contentType) => ({ label: contentType.info.displayName, @@ -114,7 +113,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.singleTypes.label', defaultMessage: 'Single Types', }), - children: singleTypes.map((contentType) => ({ + children: [...singleTypes].map((contentType) => ({ label: contentType.info.displayName, value: contentType.uid, })), @@ -178,24 +177,10 @@ export function WorkflowAttributes({ ); } -const ContentTypeType = PropTypes.shape({ - uid: PropTypes.string.isRequired, - info: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - }).isRequired, -}); - WorkflowAttributes.defaultProps = { canUpdate: true, - currentWorkflow: undefined, }; WorkflowAttributes.propTypes = { canUpdate: PropTypes.bool, - contentTypes: PropTypes.shape({ - collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - }).isRequired, - currentWorkflow: PropTypes.object, - workflows: PropTypes.array.isRequired, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js index 732e18a430..04edf9ac0b 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js @@ -8,31 +8,12 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; +import { REDUX_NAMESPACE } from '../../../constants'; import { reducer } from '../../../reducer'; import { WorkflowAttributes } from '../WorkflowAttributes'; -const CONTENT_TYPES_FIXTURE = { - collectionTypes: [ - { - uid: 'uid1', - info: { - displayName: 'Content Type 1', - }, - }, - ], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], -}; - const WORKFLOWS_FIXTURE = [ { id: 1, @@ -43,48 +24,63 @@ const WORKFLOWS_FIXTURE = [ { id: 2, - name: 'Workflow 1', - contentTypes: [], + name: 'Default 2', + contentTypes: ['uid2'], stages: [], }, ]; -const CURRENT_WORKFLOW_FIXTURE = { - ...WORKFLOWS_FIXTURE[0], +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], }; -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); +const ROLES_FIXTURE = []; +// eslint-disable-next-line react/prop-types +const ComponentFixture = ({ currentWorkflow, ...props } = {}) => { const formik = useFormik({ enableReinitialize: true, - initialValues: { - name: 'workflow name', - contentTypes: ['uid1', 'uid1'], - }, + initialValues: currentWorkflow || WORKFLOWS_FIXTURE[0], validateOnChange: false, }); return ( - - - - - - - - - - - + + + ); }; +// eslint-disable-next-line no-unused-vars const withMarkup = (query) => (text) => query((content, node) => { const hasText = (node) => node.textContent === text; @@ -93,8 +89,40 @@ const withMarkup = (query) => (text) => return hasText(node) && childrenDontHaveText; }); -const setup = (props) => ({ - ...render(), +const setup = ({ collectionTypes, singleTypes, currentWorkflow, ...props } = {}) => ({ + ...render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: { + collectionTypes: collectionTypes || CONTENT_TYPES_FIXTURE.collectionTypes, + singleTypes: singleTypes || CONTENT_TYPES_FIXTURE.singleTypes, + }, + roles: ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: currentWorkflow || WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }), user: userEvent.setup(), }); @@ -102,20 +130,18 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { it('should render values', async () => { const { getByRole, getByText, user } = setup(); - const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); - - expect(getByRole('textbox')).toHaveValue('workflow name'); - expect(getByText(/2 content types selected/i)).toBeInTheDocument(); + await waitFor(() => expect(getByText(/workflow name/i)).toBeInTheDocument()); + expect(getByRole('textbox', { name: /workflow name \*/i })).toHaveValue('Default'); + expect(getByText(/1 content type selected/i)).toBeInTheDocument(); expect(getByRole('textbox')).not.toHaveAttribute('disabled'); expect(getByRole('combobox', { name: /associated to/i })).not.toHaveAttribute('data-disabled'); - await user.click(contentTypesSelect); + await user.click(getByRole('combobox', { name: /associated to/i })); - await waitFor(() => { - expect(getByRole('option', { name: /content type 1/i })).toBeInTheDocument(); - expect(getByRole('option', { name: /content type 2/i })).toBeInTheDocument(); - }); + await waitFor(() => + expect(getByRole('option', { name: /Collection CT 1/i })).toBeInTheDocument() + ); }); it('should disabled fields if canUpdate = false', async () => { @@ -127,20 +153,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not collection-types', async () => { + it('should not render a collection-type group if there are no collection-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - }, + collectionTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -153,20 +168,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not single-types', async () => { + it('should not render a collection-type group if there are no single-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - - singleTypes: [], - }, + singleTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -188,23 +192,29 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render assigned content-types to the current workflow', async () => { + it('should not render the assigned content-types notice to the current workflow', async () => { const { getByRole, queryByText, user } = setup(); + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); + const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const queryByTextWithMarkup = withMarkup(queryByText); await user.click(contentTypesSelect); - await waitFor(() => { - expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument(); - }); + await waitFor(() => + expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument() + ); }); it('should render assigned content-types to the other workflows', async () => { - const { getByRole, getByText, user } = setup({ - currentWorkflow: { ...WORKFLOWS_FIXTURE[1] }, - }); + const { getByRole, getByText, user } = setup(); + + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const getByTextWithMarkup = withMarkup(getByText); @@ -212,11 +222,11 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); - it('should render assigned content-types to the other workflows, when currentWorkflow is not passed', async () => { + it('should render assigned content-types of other workflows, when currentWorkflow is not passed', async () => { const { getByRole, getByText, user } = setup({ currentWorkflow: undefined, }); @@ -227,7 +237,7 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js index aa63aa210b..742c843afa 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js @@ -3,7 +3,11 @@ import { lightTheme } from '@strapi/design-system'; export const REDUX_NAMESPACE = 'settings_review-workflows'; export const ACTION_RESET_WORKFLOW = `Settings/Review_Workflows/RESET_WORKFLOW`; +export const ACTION_SET_CONTENT_TYPES = `Settings/Review_Workflows/SET_CONTENT_TYPES`; +export const ACTION_SET_IS_LOADING = `Settings/Review_Workflows/SET_IS_LOADING`; +export const ACTION_SET_ROLES = `Settings/Review_Workflows/SET_ROLES`; export const ACTION_SET_WORKFLOW = `Settings/Review_Workflows/SET_WORKFLOW`; +export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`; export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`; export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`; export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js index 466fe26091..41569efe7c 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js @@ -15,10 +15,18 @@ import { useMutation } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { useLicenseLimits } from '../../../../../../hooks'; -import { addStage, resetWorkflow } from '../../actions'; +import { + addStage, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -29,7 +37,8 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { selectIsLoading, selectIsWorkflowDirty, selectCurrentWorkflow } from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsCreateView() { @@ -39,13 +48,12 @@ export function ReviewWorkflowsCreateView() { const { formatAPIError } = useAPIErrorHandler(); const dispatch = useDispatch(); const toggleNotification = useNotification(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { isLoading: isWorkflowLoading, meta, workflows } = useReviewWorkflows(); - const { - clientState: { - currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const { isLoading: isLoadingWorkflow, meta, workflows } = useReviewWorkflows(); + const { isLoading: isLoadingRoles, roles } = useAdminRoles(); + const isLoading = useSelector(selectIsLoading); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); const [showLimitModal, setShowLimitModal] = React.useState(false); const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits(); const [initialErrors, setInitialErrors] = React.useState(null); @@ -54,7 +62,7 @@ export function ReviewWorkflowsCreateView() { const limits = getFeature('review-workflows'); const contentTypesFromOtherWorkflows = workflows.flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -167,13 +175,36 @@ export function ReviewWorkflowsCreateView() { React.useEffect(() => { dispatch(resetWorkflow()); + if (!isLoadingWorkflow) { + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(roles)); + } + + dispatch(setIsLoading(isLoadingContentTypes || isLoadingRoles)); + // Create an empty default stage dispatch( addStage({ name: '', }) ); - }, [dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingRoles, + isLoadingWorkflow, + roles, + singleTypes, + workflows, + ]); /** * If the current license has a limit: @@ -189,7 +220,7 @@ export function ReviewWorkflowsCreateView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowsTotal >= parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -205,7 +236,7 @@ export function ReviewWorkflowsCreateView() { } }, [ isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowsTotal, currentWorkflow.stages.length, @@ -225,7 +256,7 @@ export function ReviewWorkflowsCreateView() { type="submit" size="M" disabled={!currentWorkflowIsDirty} - isLoading={isLoading} + isLoading={isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -247,7 +278,7 @@ export function ReviewWorkflowsCreateView() { /> - {isLoadingModels ? ( + {isLoading ? ( {formatMessage({ id: 'Settings.review-workflows.page.isLoading', @@ -256,10 +287,7 @@ export function ReviewWorkflowsCreateView() { ) : ( - + )} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js index e86286af07..15f8dce1a9 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js @@ -16,11 +16,19 @@ import { useMutation } from 'react-query'; import { useSelector, useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors'; import { useLicenseLimits } from '../../../../../../hooks'; -import { resetWorkflow, setWorkflow } from '../../actions'; +import { + resetWorkflow, + setIsLoading, + setWorkflow, + setContentTypes, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -31,7 +39,14 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectHasDeletedServerStages, + selectIsLoading, + selectServerState, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsEditView() { @@ -42,29 +57,19 @@ export function ReviewWorkflowsEditView() { const { put } = useFetchClient(); const { formatAPIError } = useAPIErrorHandler(); const toggleNotification = useNotification(); - const { - isLoading: isWorkflowLoading, - meta, - workflows, - status: workflowStatus, - refetch, - } = useReviewWorkflows(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { - status, - clientState: { - currentWorkflow: { - data: currentWorkflow, - isDirty: currentWorkflowIsDirty, - hasDeletedServerStages, - }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { isLoading: isLoadingWorkflow, meta, workflows, refetch } = useReviewWorkflows(); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const serverState = useSelector(selectServerState); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const hasDeletedServerStages = useSelector(selectHasDeletedServerStages); + const isLoading = useSelector(selectIsLoading); const { allowedActions: { canDelete, canUpdate }, } = useRBAC(permissions.settings['review-workflows']); const [savePrompts, setSavePrompts] = React.useState({}); const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits(); + const { isLoading: isLoadingRoles, roles } = useAdminRoles(); const [showLimitModal, setShowLimitModal] = React.useState(false); const [initialErrors, setInitialErrors] = React.useState(null); @@ -73,7 +78,7 @@ export function ReviewWorkflowsEditView() { .filter((workflow) => workflow.id !== parseInt(workflowId, 10)) .flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -98,7 +103,29 @@ export function ReviewWorkflowsEditView() { setInitialErrors(null); try { - const res = await mutateAsync({ workflow }); + const res = await mutateAsync({ + workflow: { + ...workflow, + + // compare permissions of stages and only submit the ones which have + // changed; this enables partial updates e.g. for users who don't have + // permissions to see roles + stages: workflow.stages.map((stage) => { + const hasUpdatedPermissions = (stage.permissions ?? []).some( + ({ permission: { role } }) => + !serverState.workflow.stages.find( + (stage) => + !!(stage.permissions ?? []).find((permission) => permission.role === role) + ) + ); + + return { + ...stage, + permissions: hasUpdatedPermissions ? stage.permissions : undefined, + }; + }), + }, + }); return res; } catch (error) { @@ -194,14 +221,37 @@ export function ReviewWorkflowsEditView() { const limits = getFeature('review-workflows'); React.useEffect(() => { - dispatch(setWorkflow({ status: workflowStatus, data: workflow })); + if (!isLoadingWorkflow) { + dispatch(setWorkflow({ workflow })); + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(roles)); + } + + dispatch(setIsLoading(isLoadingWorkflow || isLoadingContentTypes || isLoadingRoles)); // reset the state to the initial state to avoid flashes if a user // navigates from an edit-view to a create-view return () => { dispatch(resetWorkflow()); }; - }, [workflowStatus, workflow, dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingWorkflow, + isLoadingRoles, + roles, + singleTypes, + workflow, + workflows, + ]); /** * If the current license has a limit: @@ -217,7 +267,7 @@ export function ReviewWorkflowsEditView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowCount > parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -234,7 +284,7 @@ export function ReviewWorkflowsEditView() { }, [ currentWorkflow.stages.length, isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowCount, meta.workflowsTotal, @@ -259,7 +309,7 @@ export function ReviewWorkflowsEditView() { disabled={!currentWorkflowIsDirty} // if the confirm dialog is open the loading state is on // the confirm button already - loading={!Object.keys(savePrompts).length > 0 && isLoading} + loading={!Object.keys(savePrompts).length > 0 && isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -269,7 +319,7 @@ export function ReviewWorkflowsEditView() { ) } subtitle={ - currentWorkflow.stages.length > 0 && + !isLoadingWorkflow && formatMessage( { id: 'Settings.review-workflows.page.subtitle', @@ -282,7 +332,7 @@ export function ReviewWorkflowsEditView() { /> - {isLoadingModels || status === 'loading' ? ( + {isLoading ? ( {formatMessage({ @@ -293,12 +343,7 @@ export function ReviewWorkflowsEditView() { ) : ( - + ({ ...stage, // A safety net in case a stage does not have a color assigned; - // this normallly should not happen + // this should not happen color: stage?.color ?? STAGE_COLOR_DEFAULT, })), }; } + break; + } - draft.clientState.currentWorkflow.hasDeletedServerStages = false; + case ACTION_SET_WORKFLOWS: { + draft.serverState.workflows = payload; break; } @@ -71,12 +95,6 @@ export function reducer(state = initialState, action) { (stage) => (stage?.id ?? stage.__temp_key__) !== stageId ); - if (!currentWorkflow.hasDeletedServerStages) { - draft.clientState.currentWorkflow.hasDeletedServerStages = !!( - state.serverState.workflow?.stages ?? [] - ).find((stage) => stage.id === stageId); - } - break; } @@ -149,16 +167,6 @@ export function reducer(state = initialState, action) { default: break; } - - if (state.clientState.currentWorkflow.data && draft.serverState.workflow) { - draft.clientState.currentWorkflow.isDirty = !isEqual( - current(draft.clientState.currentWorkflow).data, - draft.serverState.workflow - ); - } else { - // if there is no workflow on the server, the workflow is awalys considered dirty - draft.clientState.currentWorkflow.isDirty = true; - } }); } diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js index 1c4d4d84f5..09f059d08f 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js @@ -3,6 +3,9 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, @@ -33,10 +36,57 @@ describe('Admin | Settings | Review Workflows | reducer', () => { state = initialState; }); + test('ACTION_SET_IS_LOADING', () => { + const action = { + type: ACTION_SET_IS_LOADING, + payload: true, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + isLoading: true, + }), + }) + ); + }); + + test('ACTION_SET_CONTENT_TYPES', () => { + const action = { + type: ACTION_SET_CONTENT_TYPES, + payload: { collectionTypes: [{ id: 1 }] }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + contentTypes: { + collectionTypes: [{ id: 1 }], + }, + }), + }) + ); + }); + + test('ACTION_SET_ROLES', () => { + const action = { + type: ACTION_SET_ROLES, + payload: [{ id: 1 }], + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + roles: [{ id: 1 }], + }), + }) + ); + }); + test('ACTION_SET_WORKFLOW with workflows', () => { const action = { type: ACTION_SET_WORKFLOW, - payload: { status: 'loading-state', workflow: WORKFLOW_FIXTURE }, + payload: WORKFLOW_FIXTURE, }; const DEFAULT_WORKFLOW_FIXTURE = { @@ -51,15 +101,12 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect(reducer(state, action)).toStrictEqual( expect.objectContaining({ - status: 'loading-state', serverState: expect.objectContaining({ workflow: WORKFLOW_FIXTURE, }), clientState: expect.objectContaining({ currentWorkflow: expect.objectContaining({ data: DEFAULT_WORKFLOW_FIXTURE, - isDirty: false, - hasDeletedServerStages: false, }), }), }) @@ -78,7 +125,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { workflow: WORKFLOW_FIXTURE, }, clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -95,107 +142,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to true if stageId exists on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to false if stageId does not exist on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: { - ...WORKFLOW_FIXTURE, - stages: [...WORKFLOW_FIXTURE.stages, { __temp_key__: 3, name: 'something' }], - }, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: false, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - keep hasDeletedServerStages true as soon as one server stage has been deleted', () => { - const actionDeleteServerStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - const actionDeleteClientStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - state = reducer(state, actionDeleteServerStage); - state = reducer(state, actionDeleteClientStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - test('ACTION_ADD_STAGE', () => { const action = { type: ACTION_ADD_STAGE, @@ -206,7 +152,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -239,7 +185,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: null, isDirty: false }, + currentWorkflow: { data: null }, }, }; @@ -287,7 +233,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -320,7 +266,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -343,52 +289,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('properly compare serverState and clientState and set isDirty accordingly', () => { - const actionAddStage = { - type: ACTION_ADD_STAGE, - payload: { name: 'something' }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, - }, - }; - - state = reducer(state, actionAddStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: true, - }), - }), - }) - ); - - const actionDeleteStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = reducer(state, actionDeleteStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: false, - }), - }), - }) - ); - }); - test('ACTION_UPDATE_STAGE_POSITION', () => { const action = { type: ACTION_UPDATE_STAGE_POSITION, @@ -403,7 +303,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -418,7 +317,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-1' }), ], }), - isDirty: true, }), }), }) @@ -439,7 +337,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -454,7 +351,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -475,7 +371,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -490,7 +385,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -511,7 +405,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -523,7 +416,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { data: expect.objectContaining({ name: 'test', }), - isDirty: true, }), }), }) @@ -543,7 +435,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -556,7 +447,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { name: '', stages: [], }), - isDirty: true, }), }), }) diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js new file mode 100644 index 0000000000..a65581e83a --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js @@ -0,0 +1,45 @@ +import isEqual from 'lodash/isEqual'; +import { createSelector } from 'reselect'; + +import { REDUX_NAMESPACE } from './constants'; +import { initialState } from './reducer'; + +export const selectNamespace = (state) => state[REDUX_NAMESPACE] ?? initialState; + +export const selectContentTypes = createSelector( + selectNamespace, + ({ serverState: { contentTypes } }) => contentTypes +); + +export const selectRoles = createSelector(selectNamespace, ({ serverState: { roles } }) => roles); + +export const selectCurrentWorkflow = createSelector( + selectNamespace, + ({ clientState: { currentWorkflow } }) => currentWorkflow.data +); + +export const selectWorkflows = createSelector( + selectNamespace, + ({ serverState: { workflows } }) => workflows +); + +export const selectIsWorkflowDirty = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !isEqual(serverState.workflow, currentWorkflow.data) +); + +export const selectHasDeletedServerStages = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !(serverState.workflow?.stages ?? []).every( + (stage) => !!currentWorkflow.data.stages.find(({ id }) => id === stage.id) + ) +); + +export const selectIsLoading = createSelector( + selectNamespace, + ({ clientState: { isLoading } }) => isLoading +); + +export const selectServerState = createSelector(selectNamespace, ({ serverState }) => serverState); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js index 9fe921b9da..f7c65a84d1 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js @@ -145,4 +145,79 @@ describe('Settings | Review Workflows | validateWorkflow()', () => { } `); }); + + test('stages.permissions: array', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [{ role: 1, action: 'admin::review-workflow.stage.transition' }], + }, + ], + }) + ).toEqual(true); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [], + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "Must be either an array or undefined", + }, + ], + } + `); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: { role: '1', action: 'admin::review-workflow.stage.transition' }, + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "stages[0].permissions must be a \`array\` type, but the final value was: \`{ + "role": "\\"1\\"", + "action": "\\"admin::review-workflow.stage.transition\\"" + }\`.", + }, + ], + } + `); + }); + + test('stages.permissions: undefined', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: undefined, + }, + ], + }) + ).toEqual(true); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js index cdac8102ce..1c1fc22225 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js @@ -57,6 +57,33 @@ export async function validateWorkflow({ values, formatMessage }) { }) ) .matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), + + permissions: yup + .array( + yup.object({ + role: yup + .number() + .strict() + .typeError( + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions.role.number', + defaultMessage: 'Role must be of type number', + }) + ).required, + action: yup.string().required({ + id: 'Settings.review-workflows.validation.stage.permissions.action.required', + defaultMessage: 'Action is a required argument', + }), + }) + ) + .strict() + .min( + 1, + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions', + defaultMessage: 'Must be either an array or undefined', + }) + ), }) ) .min(1), From 308c458753aad0695608326a181310915b9f20a9 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 8 Aug 2023 12:43:54 +0200 Subject: [PATCH 18/32] Fix: Make return values of data-fetching hooks stable --- .../admin/src/hooks/useAdminRoles/index.js | 24 ++++++++++++------ .../src/hooks/useAdminUsers/useAdminUsers.js | 22 ++++++++++------ .../hooks/useContentTypes/useContentTypes.js | 25 +++++++++++++------ .../hooks/useReviewWorkflows.js | 23 +++++++++++------ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index a0ea4520aa..c934afbbc9 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useCollator, useFetchClient } from '@strapi/helper-plugin'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; @@ -22,16 +24,24 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { queryOptions ); - let roles = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const roles = React.useMemo(() => { + let roles = []; - if (id && data) { - roles = [data.data]; - } else if (Array.isArray(data?.data)) { - roles = data.data; - } + if (id && data) { + roles = [data.data]; + } else if (Array.isArray(data?.data)) { + roles = data.data; + } + + return [...roles].sort((a, b) => formatter.compare(a.name, b.name)); + }, [data, id, formatter]); return { - roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), + roles, error, isError, isLoading, diff --git a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js index a4ea385bb1..f477e1aa5e 100644 --- a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js +++ b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,17 +22,23 @@ export function useAdminUsers(params = {}, queryOptions = {}) { queryOptions ); - let users = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const users = React.useMemo(() => { + if (id && data) { + return [data]; + } else if (Array.isArray(data?.results)) { + return data.results; + } - if (id && data) { - users = [data]; - } else if (Array.isArray(data?.results)) { - users = data.results; - } + return []; + }, [data, id]); return { users, - pagination: data?.pagination ?? null, + pagination: React.useMemo(() => data?.pagination ?? null, [data?.pagination]), isLoading, isError, refetch, diff --git a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js index 52ba5fe4fb..1917f9bc69 100644 --- a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js +++ b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useAPIErrorHandler, useFetchClient, useNotification } from '@strapi/helper-plugin'; import { useQueries } from 'react-query'; @@ -29,16 +31,25 @@ export function useContentTypes() { const [components, contentTypes] = queries; const isLoading = components.isLoading || contentTypes.isLoading; - const collectionTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed - ); - const singleTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed - ); + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const collectionTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); + + const singleTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); return { isLoading, - components: components?.data ?? [], + components: React.useMemo(() => components?.data ?? [], [components?.data]), collectionTypes, singleTypes, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js index 2cc79fedb5..696dff44bf 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,18 +22,25 @@ export function useReviewWorkflows(params = {}) { } ); - let workflows = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks - if (id && data?.data) { - workflows = [data.data]; - } else if (Array.isArray(data?.data)) { - workflows = data.data; - } + const workflows = React.useMemo(() => { + if (id && data?.data) { + return [data.data]; + } else if (Array.isArray(data?.data)) { + return data.data; + } + + return []; + }, [data?.data, id]); return { // meta contains e.g. the total of all workflows. we can not use // the pagination object here, because the list is not paginated. - meta: data?.meta ?? {}, + meta: React.useMemo(() => data?.meta ?? {}, [data?.meta]), workflows, isLoading, status, From 40d1cf25757e02da91ab0f8624286f12837c471b Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 1 Aug 2023 14:28:44 +0200 Subject: [PATCH 19/32] Enhancement: Render permissions for each workflow stage --- .../admin/src/hooks/useAdminRoles/index.js | 2 +- .../useLicenseLimits/useLicenseLimits.js | 12 +- .../pages/ReviewWorkflows/actions/index.js | 39 ++- .../actions/tests/index.test.js | 79 ++++++- .../components/Stages/Stage/Stage.js | 112 ++++++++- .../Stages/Stage/tests/Stage.test.js | 214 ++++++++++++++--- .../components/Stages/tests/Stages.test.js | 88 +++---- .../WorkflowAttributes/WorkflowAttributes.js | 31 +-- .../tests/WorkflowAttributes.test.js | 198 ++++++++-------- .../pages/ReviewWorkflows/constants.js | 4 + .../pages/CreateView/CreateView.js | 89 +++++-- .../pages/EditView/EditView.js | 134 ++++++++--- .../pages/ReviewWorkflows/reducer/index.js | 60 +++-- .../reducer/tests/index.test.js | 222 +++++------------- .../pages/ReviewWorkflows/selectors.js | 45 ++++ .../utils/tests/validateWorkflow.test.js | 75 ++++++ .../ReviewWorkflows/utils/validateWorkflow.js | 27 +++ 17 files changed, 959 insertions(+), 472 deletions(-) create mode 100644 packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index 86ffcda4dc..a0ea4520aa 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -31,7 +31,7 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { } return { - roles: roles.sort((a, b) => formatter.compare(a.name, b.name)), + roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), error, isError, isLoading, diff --git a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js index 774d49c9dc..60e690c5e3 100644 --- a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js +++ b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js @@ -3,7 +3,7 @@ import * as React from 'react'; import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; -export function useLicenseLimits({ enabled } = { enabled: true }) { +export function useLicenseLimits(queryOptions = {}) { const { get } = useFetchClient(); const { data, isError, isLoading } = useQuery( ['ee', 'license-limit-info'], @@ -15,11 +15,17 @@ export function useLicenseLimits({ enabled } = { enabled: true }) { return data; }, { - enabled, + ...queryOptions, + + // the request is expected to fail sometimes if a user does not + // have permissions + retry: false, } ); - const license = data ?? {}; + const license = React.useMemo(() => { + return data ?? {}; + }, [data]); const getFeature = React.useCallback( (name) => { diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js index 9ebb777b13..5711ee9924 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js @@ -2,19 +2,27 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, + ACTION_SET_WORKFLOWS, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, ACTION_UPDATE_WORKFLOW, } from '../constants'; -export function setWorkflow({ status, data }) { +export function setWorkflow({ workflow }) { return { type: ACTION_SET_WORKFLOW, - payload: { - status, - workflow: data, - }, + payload: workflow, + }; +} + +export function setWorkflows({ workflows }) { + return { + type: ACTION_SET_WORKFLOWS, + payload: workflows, }; } @@ -66,3 +74,24 @@ export function resetWorkflow() { type: ACTION_RESET_WORKFLOW, }; } + +export function setContentTypes(payload) { + return { + type: ACTION_SET_CONTENT_TYPES, + payload, + }; +} + +export function setRoles(payload) { + return { + type: ACTION_SET_ROLES, + payload, + }; +} + +export function setIsLoading(isLoading) { + return { + type: ACTION_SET_IS_LOADING, + payload: isLoading, + }; +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js index 0cb1ac3177..45e0e2699e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js @@ -1,19 +1,42 @@ -import { addStage, deleteStage, setWorkflow, updateStage } from '..'; +import { + addStage, + deleteStage, + setWorkflow, + setWorkflows, + updateStage, + updateStagePosition, + updateWorkflow, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, +} from '..'; import { ACTION_SET_WORKFLOW, ACTION_DELETE_STAGE, ACTION_ADD_STAGE, ACTION_UPDATE_STAGE, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, + ACTION_SET_WORKFLOWS, + ACTION_UPDATE_STAGE_POSITION, + ACTION_RESET_WORKFLOW, + ACTION_UPDATE_WORKFLOW, } from '../../constants'; describe('Admin | Settings | Review Workflow | actions', () => { test('setWorkflow()', () => { - expect(setWorkflow({ status: 'loading', data: null, something: 'else' })).toStrictEqual({ + expect(setWorkflow({ workflow: null, something: 'else' })).toStrictEqual({ type: ACTION_SET_WORKFLOW, - payload: { - status: 'loading', - workflow: null, - }, + payload: null, + }); + }); + + test('setWorkflows()', () => { + expect(setWorkflows({ workflows: [] })).toStrictEqual({ + type: ACTION_SET_WORKFLOWS, + payload: [], }); }); @@ -49,4 +72,48 @@ describe('Admin | Settings | Review Workflow | actions', () => { }, }); }); + + test('updateStagePosition()', () => { + expect(updateStagePosition(1, 2)).toStrictEqual({ + type: ACTION_UPDATE_STAGE_POSITION, + payload: { + newIndex: 2, + oldIndex: 1, + }, + }); + }); + + test('updateWorkflow()', () => { + expect(updateWorkflow({})).toStrictEqual({ + type: ACTION_UPDATE_WORKFLOW, + payload: {}, + }); + }); + + test('resetWorkflow()', () => { + expect(resetWorkflow()).toStrictEqual({ + type: ACTION_RESET_WORKFLOW, + }); + }); + + test('setContentTypes()', () => { + expect(setContentTypes({})).toStrictEqual({ + type: ACTION_SET_CONTENT_TYPES, + payload: {}, + }); + }); + + test('setRoles()', () => { + expect(setRoles({})).toStrictEqual({ + type: ACTION_SET_ROLES, + payload: {}, + }); + }); + + test('setIsLoading()', () => { + expect(setIsLoading(true)).toStrictEqual({ + type: ACTION_SET_IS_LOADING, + payload: true, + }); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js index efd000bc14..5d420eb95e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js @@ -9,25 +9,34 @@ import { Grid, GridItem, IconButton, + MultiSelect, + MultiSelectGroup, + MultiSelectOption, SingleSelect, SingleSelectOption, TextInput, VisuallyHidden, } from '@strapi/design-system'; -import { useTracking } from '@strapi/helper-plugin'; +import { NotAllowedInput, useTracking } from '@strapi/helper-plugin'; import { Drag, Trash } from '@strapi/icons'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; import { useDragAndDrop } from '../../../../../../../../../admin/src/content-manager/hooks'; import { composeRefs } from '../../../../../../../../../admin/src/content-manager/utils'; import { deleteStage, updateStage, updateStagePosition } from '../../../actions'; import { DRAG_DROP_TYPES } from '../../../constants'; +import { selectRoles } from '../../../selectors'; import { getAvailableStageColors, getStageColorByHex } from '../../../utils/colors'; +const NestedOption = styled(MultiSelectOption)` + padding-left: ${({ theme }) => theme.spaces[7]}; +`; + const AVAILABLE_COLORS = getAvailableStageColors(); function StageDropPreview() { @@ -144,6 +153,10 @@ export function Stage({ const [isOpen, setIsOpen] = React.useState(isOpenDefault); const [nameField, nameMeta, nameHelper] = useField(`stages.${index}.name`); const [colorField, colorMeta, colorHelper] = useField(`stages.${index}.color`); + const [permissionsField, permissionsMeta, permissionsHelper] = useField( + `stages.${index}.permissions` + ); + const roles = useSelector(selectRoles); const [{ handlerId, isDragging, handleKeyDown }, stageRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(canReorder, { index, @@ -171,12 +184,17 @@ export function Stage({ color: hex, })); + const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; + + const filteredRoles = roles + // Super admins always have permissions to do everything and therefore + // there is no point for this role to show up in the role combobox + .filter((role) => role.code !== 'strapi-super-admin'); + React.useEffect(() => { dragPreviewRef(getEmptyImage(), { captureDraggingState: false }); }, [dragPreviewRef, index]); - const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; - return ( {liveText && {liveText}} @@ -315,6 +333,92 @@ export function Stage({ })} + + + {filteredRoles.length === 0 ? ( + + ) : ( + { + // Because the select components expects strings for values, but + // the yup schema validates numbers are sent to the API, we have + // to coerce the string value back to a number + const nextValues = values.map((value) => ({ + role: parseInt(value, 10), + action: 'admin::review-workflows.stage.transition', + })); + + permissionsHelper.setValue(nextValues); + + dispatch(updateStage(id, { permissions: nextValues })); + }} + placeholder={formatMessage({ + id: 'Settings.review-workflows.stage.permissions.placeholder', + defaultMessage: 'Select a role', + })} + required + // The Select component expects strings for values + value={(permissionsField.value ?? []).map((permission) => `${permission.role}`)} + withTags + > + {[ + { + value: null, + label: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.allRoles.label', + defaultMessage: 'All roles', + }), + children: filteredRoles.map((role) => ({ + value: `${role.id}`, + label: role.name, + })), + }, + ].map((role) => { + if ('children' in role) { + return ( + child.value)} + > + {role.children.map((role) => { + return ( + + {role.label} + + ); + })} + + ); + } + + return ( + + {role.label} + + ); + })} + + )} + diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js index f31a7a8290..1ee2a8f7e6 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js @@ -1,16 +1,16 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../../admin/src/core/store/configureStore'; -import { STAGE_COLOR_DEFAULT } from '../../../../constants'; +import { REDUX_NAMESPACE, STAGE_COLOR_DEFAULT } from '../../../../constants'; import { reducer } from '../../../../reducer'; import { Stage } from '../Stage'; @@ -19,18 +19,86 @@ const STAGES_FIXTURE = { index: 0, }; +const WORKFLOWS_FIXTURE = [ + { + id: 1, + name: 'Default', + contentTypes: ['uid1'], + stages: [], + }, + + { + id: 2, + name: 'Default 2', + contentTypes: ['uid2'], + stages: [], + }, +]; + +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], +}; + +const ROLES_FIXTURE = [ + { + id: 1, + code: 'strapi-editor', + name: 'Editor', + }, + + { + id: 2, + code: 'strapi-author', + name: 'Author', + }, + + { + id: 3, + code: 'strapi-super-admin', + name: 'Super Admin', + }, +]; + const ComponentFixture = ({ // eslint-disable-next-line react/prop-types stages = [ { color: STAGE_COLOR_DEFAULT, name: 'something', + permissions: [{ role: 1, action: 'admin::review-workflows.stage.transition' }], }, ], ...props }) => { - const store = configureStore([], [reducer]); - const formik = useFormik({ enableReinitialize: true, initialValues: { @@ -40,21 +108,43 @@ const ComponentFixture = ({ }); return ( - - - - - - - - - - - + + + ); }; -const setup = (props) => render(); +const setup = ({ roles, ...props } = {}) => + render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: CONTENT_TYPES_FIXTURE, + roles: roles || ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }); const user = userEvent.setup(); @@ -72,11 +162,25 @@ describe('Admin | Settings | Review Workflow | Stage', () => { // does not have better identifiers await user.click(container.querySelector('button[aria-expanded]')); - expect(queryByRole('textbox')).toBeInTheDocument(); - expect(getByRole('textbox').value).toBe('something'); + // Expect the accordion header to have the same value as the textbox + expect(getByRole('button', { name: /something/i })); expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name'); - expect(getByRole('combobox')).toHaveTextContent('Blue'); + // Name + expect(queryByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox').value).toBe('something'); + + // Color combobox + await waitFor(() => + expect(getByRole('combobox', { name: /color/i })).toHaveTextContent('Blue') + ); + + // Permissions combobox + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toHaveTextContent('Editor') + ); expect( queryByRole('button', { @@ -88,27 +192,31 @@ describe('Admin | Settings | Review Workflow | Stage', () => { it('should open the accordion panel if isOpen = true', async () => { const { queryByRole } = setup({ isOpen: true }); - expect(queryByRole('textbox')).toBeInTheDocument(); + await waitFor(() => expect(queryByRole('textbox')).toBeInTheDocument()); }); it('should not render the delete button if canDelete=false', async () => { const { queryByRole } = setup({ isOpen: true, canDelete: false }); - expect( - queryByRole('button', { - name: /delete stage/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /delete stage/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not render delete drag button if canUpdate=false', async () => { const { queryByRole } = setup({ isOpen: true, canUpdate: false }); - expect( - queryByRole('button', { - name: /drag/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /drag/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not crash on a custom color code', async () => { @@ -131,7 +239,49 @@ describe('Admin | Settings | Review Workflow | Stage', () => { await user.click(container.querySelector('button[aria-expanded]')); + // Name expect(getByRole('textbox')).toHaveAttribute('disabled'); - expect(getByRole('combobox')).toHaveAttribute('data-disabled'); + + // Color + expect(getByRole('combobox', { name: /color/i })).toHaveAttribute('data-disabled'); + + // Permissions + expect(getByRole('combobox', { name: /roles that can change this stage/i })).toHaveAttribute( + 'data-disabled' + ); + }); + + it('should render a list of all available roles (except super admins)', async () => { + const { container, getByRole, queryByRole } = setup({ canUpdate: true }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toBeInTheDocument() + ); + + await user.click(getByRole('combobox', { name: /roles that can change this stage/i })); + + await waitFor(() => expect(getByRole('option', { name: /All roles/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Editor/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Author/i })).toBeInTheDocument()); + await waitFor(() => + expect(queryByRole('option', { name: /Super Admin/i })).not.toBeInTheDocument() + ); + }); + + it('should render a no permissions fallback, if no roles are available', async () => { + const { container, getByText } = setup({ + canUpdate: true, + roles: [...ROLES_FIXTURE].filter((role) => role.code === 'strapi-super-admin'), + }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect(getByText(/you don’t have the permission to see roles/i)).toBeInTheDocument() + ); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js index 07f6286491..9877245eed 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js @@ -1,9 +1,8 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { fireEvent, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; @@ -11,7 +10,7 @@ import { Provider } from 'react-redux'; import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; import * as actions from '../../../actions'; -import { ACTION_SET_WORKFLOW, STAGE_COLOR_DEFAULT } from '../../../constants'; +import { STAGE_COLOR_DEFAULT } from '../../../constants'; import { reducer } from '../../../reducer'; import { Stages } from '../Stages'; @@ -21,6 +20,15 @@ jest.mock('../../../actions', () => ({ ...jest.requireActual('../../../actions'), })); +// A single stage needs a formik provider, which is a bit complicated to setup. +// Since we don't want to test the single stages, but the overall composition +// it is the easiest for the test setup to just render an id instead of the +// whole component. +jest.mock('../Stage', () => ({ + __esModule: true, + Stage: ({ id }) => id, +})); + const STAGES_FIXTURE = [ { id: 1, @@ -35,44 +43,25 @@ const STAGES_FIXTURE = [ }, ]; -const WORKFLOWS_FIXTURE = [ - { - id: 1, - stages: STAGES_FIXTURE, - }, -]; +const setup = (props) => ({ + ...render(, { + wrapper({ children }) { + const store = configureStore([], [reducer]); -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); - - store.dispatch({ type: ACTION_SET_WORKFLOW, payload: { workflows: WORKFLOWS_FIXTURE } }); - - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - stages: STAGES_FIXTURE, + return ( + + + + {children} + + + + ); }, - validateOnChange: false, - }); + }), - return ( - - - - - - - - - - - - ); -}; - -const setup = (props) => render(); - -const user = userEvent.setup(); + user: userEvent.setup(), +}); describe('Admin | Settings | Review Workflow | Stages', () => { beforeEach(() => { @@ -82,8 +71,8 @@ describe('Admin | Settings | Review Workflow | Stages', () => { it('should render a list of stages', () => { const { getByText } = setup(); - expect(getByText(STAGES_FIXTURE[0].name)).toBeInTheDocument(); - expect(getByText(STAGES_FIXTURE[1].name)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[0].id)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[1].id)).toBeInTheDocument(); }); it('should render a "add new stage" button', () => { @@ -93,7 +82,7 @@ describe('Admin | Settings | Review Workflow | Stages', () => { }); it('should append a new stage when clicking "add new stage"', async () => { - const { getByRole } = setup(); + const { getByRole, user } = setup(); const spy = jest.spyOn(actions, 'addStage'); await user.click( @@ -106,23 +95,6 @@ describe('Admin | Settings | Review Workflow | Stages', () => { expect(spy).toBeCalledWith({ name: '' }); }); - it('should update the name of a stage by changing the input value', async () => { - const { queryByRole, getByRole } = setup(); - const spy = jest.spyOn(actions, 'updateStage'); - - await user.click(getByRole('button', { name: /stage-2/i })); - - const input = queryByRole('textbox', { - name: /stage name/i, - }); - - fireEvent.change(input, { target: { value: 'New name' } }); - - expect(spy).toBeCalledWith(2, { - name: 'New name', - }); - }); - it('should not render the "add stage" button if canUpdate = false', () => { const { queryByText } = setup({ canUpdate: false }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js index 46f6c45f4e..af8f0e72c3 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js @@ -13,10 +13,11 @@ import { useCollator } from '@strapi/helper-plugin'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { updateWorkflow } from '../../actions'; +import { selectContentTypes, selectCurrentWorkflow, selectWorkflows } from '../../selectors'; const NestedOption = styled(MultiSelectOption)` padding-left: ${({ theme }) => theme.spaces[7]}; @@ -26,14 +27,12 @@ const ContentTypeTakeNotice = styled(Typography)` font-style: italic; `; -export function WorkflowAttributes({ - canUpdate, - contentTypes: { collectionTypes, singleTypes }, - currentWorkflow, - workflows, -}) { +export function WorkflowAttributes({ canUpdate }) { const { formatMessage, locale } = useIntl(); const dispatch = useDispatch(); + const { collectionTypes, singleTypes } = useSelector(selectContentTypes); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const workflows = useSelector(selectWorkflows); const [nameField, nameMeta, nameHelper] = useField('name'); const [contentTypesField, contentTypesMeta, contentTypesHelper] = useField('contentTypes'); const formatter = useCollator(locale, { @@ -97,7 +96,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.collectionTypes.label', defaultMessage: 'Collection Types', }), - children: collectionTypes + children: [...collectionTypes] .sort((a, b) => formatter.compare(a.info.displayName, b.info.displayName)) .map((contentType) => ({ label: contentType.info.displayName, @@ -114,7 +113,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.singleTypes.label', defaultMessage: 'Single Types', }), - children: singleTypes.map((contentType) => ({ + children: [...singleTypes].map((contentType) => ({ label: contentType.info.displayName, value: contentType.uid, })), @@ -178,24 +177,10 @@ export function WorkflowAttributes({ ); } -const ContentTypeType = PropTypes.shape({ - uid: PropTypes.string.isRequired, - info: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - }).isRequired, -}); - WorkflowAttributes.defaultProps = { canUpdate: true, - currentWorkflow: undefined, }; WorkflowAttributes.propTypes = { canUpdate: PropTypes.bool, - contentTypes: PropTypes.shape({ - collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - }).isRequired, - currentWorkflow: PropTypes.object, - workflows: PropTypes.array.isRequired, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js index 732e18a430..04edf9ac0b 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js @@ -8,31 +8,12 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; +import { REDUX_NAMESPACE } from '../../../constants'; import { reducer } from '../../../reducer'; import { WorkflowAttributes } from '../WorkflowAttributes'; -const CONTENT_TYPES_FIXTURE = { - collectionTypes: [ - { - uid: 'uid1', - info: { - displayName: 'Content Type 1', - }, - }, - ], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], -}; - const WORKFLOWS_FIXTURE = [ { id: 1, @@ -43,48 +24,63 @@ const WORKFLOWS_FIXTURE = [ { id: 2, - name: 'Workflow 1', - contentTypes: [], + name: 'Default 2', + contentTypes: ['uid2'], stages: [], }, ]; -const CURRENT_WORKFLOW_FIXTURE = { - ...WORKFLOWS_FIXTURE[0], +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], }; -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); +const ROLES_FIXTURE = []; +// eslint-disable-next-line react/prop-types +const ComponentFixture = ({ currentWorkflow, ...props } = {}) => { const formik = useFormik({ enableReinitialize: true, - initialValues: { - name: 'workflow name', - contentTypes: ['uid1', 'uid1'], - }, + initialValues: currentWorkflow || WORKFLOWS_FIXTURE[0], validateOnChange: false, }); return ( - - - - - - - - - - - + + + ); }; +// eslint-disable-next-line no-unused-vars const withMarkup = (query) => (text) => query((content, node) => { const hasText = (node) => node.textContent === text; @@ -93,8 +89,40 @@ const withMarkup = (query) => (text) => return hasText(node) && childrenDontHaveText; }); -const setup = (props) => ({ - ...render(), +const setup = ({ collectionTypes, singleTypes, currentWorkflow, ...props } = {}) => ({ + ...render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: { + collectionTypes: collectionTypes || CONTENT_TYPES_FIXTURE.collectionTypes, + singleTypes: singleTypes || CONTENT_TYPES_FIXTURE.singleTypes, + }, + roles: ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: currentWorkflow || WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }), user: userEvent.setup(), }); @@ -102,20 +130,18 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { it('should render values', async () => { const { getByRole, getByText, user } = setup(); - const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); - - expect(getByRole('textbox')).toHaveValue('workflow name'); - expect(getByText(/2 content types selected/i)).toBeInTheDocument(); + await waitFor(() => expect(getByText(/workflow name/i)).toBeInTheDocument()); + expect(getByRole('textbox', { name: /workflow name \*/i })).toHaveValue('Default'); + expect(getByText(/1 content type selected/i)).toBeInTheDocument(); expect(getByRole('textbox')).not.toHaveAttribute('disabled'); expect(getByRole('combobox', { name: /associated to/i })).not.toHaveAttribute('data-disabled'); - await user.click(contentTypesSelect); + await user.click(getByRole('combobox', { name: /associated to/i })); - await waitFor(() => { - expect(getByRole('option', { name: /content type 1/i })).toBeInTheDocument(); - expect(getByRole('option', { name: /content type 2/i })).toBeInTheDocument(); - }); + await waitFor(() => + expect(getByRole('option', { name: /Collection CT 1/i })).toBeInTheDocument() + ); }); it('should disabled fields if canUpdate = false', async () => { @@ -127,20 +153,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not collection-types', async () => { + it('should not render a collection-type group if there are no collection-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - }, + collectionTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -153,20 +168,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not single-types', async () => { + it('should not render a collection-type group if there are no single-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - - singleTypes: [], - }, + singleTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -188,23 +192,29 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render assigned content-types to the current workflow', async () => { + it('should not render the assigned content-types notice to the current workflow', async () => { const { getByRole, queryByText, user } = setup(); + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); + const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const queryByTextWithMarkup = withMarkup(queryByText); await user.click(contentTypesSelect); - await waitFor(() => { - expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument(); - }); + await waitFor(() => + expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument() + ); }); it('should render assigned content-types to the other workflows', async () => { - const { getByRole, getByText, user } = setup({ - currentWorkflow: { ...WORKFLOWS_FIXTURE[1] }, - }); + const { getByRole, getByText, user } = setup(); + + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const getByTextWithMarkup = withMarkup(getByText); @@ -212,11 +222,11 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); - it('should render assigned content-types to the other workflows, when currentWorkflow is not passed', async () => { + it('should render assigned content-types of other workflows, when currentWorkflow is not passed', async () => { const { getByRole, getByText, user } = setup({ currentWorkflow: undefined, }); @@ -227,7 +237,7 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js index aa63aa210b..742c843afa 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js @@ -3,7 +3,11 @@ import { lightTheme } from '@strapi/design-system'; export const REDUX_NAMESPACE = 'settings_review-workflows'; export const ACTION_RESET_WORKFLOW = `Settings/Review_Workflows/RESET_WORKFLOW`; +export const ACTION_SET_CONTENT_TYPES = `Settings/Review_Workflows/SET_CONTENT_TYPES`; +export const ACTION_SET_IS_LOADING = `Settings/Review_Workflows/SET_IS_LOADING`; +export const ACTION_SET_ROLES = `Settings/Review_Workflows/SET_ROLES`; export const ACTION_SET_WORKFLOW = `Settings/Review_Workflows/SET_WORKFLOW`; +export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`; export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`; export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`; export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js index 466fe26091..c439146a5d 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js @@ -15,10 +15,18 @@ import { useMutation } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { useLicenseLimits } from '../../../../../../hooks'; -import { addStage, resetWorkflow } from '../../actions'; +import { + addStage, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -29,7 +37,13 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsLoading, + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectRoles, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsCreateView() { @@ -39,13 +53,15 @@ export function ReviewWorkflowsCreateView() { const { formatAPIError } = useAPIErrorHandler(); const dispatch = useDispatch(); const toggleNotification = useNotification(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { isLoading: isWorkflowLoading, meta, workflows } = useReviewWorkflows(); - const { - clientState: { - currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const { isLoading: isLoadingWorkflow, meta, workflows } = useReviewWorkflows(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); + const isLoading = useSelector(selectIsLoading); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const roles = useSelector(selectRoles); const [showLimitModal, setShowLimitModal] = React.useState(false); const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits(); const [initialErrors, setInitialErrors] = React.useState(null); @@ -54,7 +70,7 @@ export function ReviewWorkflowsCreateView() { const limits = getFeature('review-workflows'); const contentTypesFromOtherWorkflows = workflows.flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -167,13 +183,36 @@ export function ReviewWorkflowsCreateView() { React.useEffect(() => { dispatch(resetWorkflow()); + if (!isLoadingWorkflow) { + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingContentTypes || isLoadingRoles)); + // Create an empty default stage dispatch( addStage({ name: '', }) ); - }, [dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingRoles, + isLoadingWorkflow, + serverRoles, + singleTypes, + workflows, + ]); /** * If the current license has a limit: @@ -189,7 +228,7 @@ export function ReviewWorkflowsCreateView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowsTotal >= parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -205,12 +244,27 @@ export function ReviewWorkflowsCreateView() { } }, [ isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowsTotal, currentWorkflow.stages.length, ]); + React.useEffect(() => { + const filteredRoles = roles.filter((role) => role.code !== 'strapi-super-admin'); + + if (!isLoading && filteredRoles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + return ( <> @@ -225,7 +279,7 @@ export function ReviewWorkflowsCreateView() { type="submit" size="M" disabled={!currentWorkflowIsDirty} - isLoading={isLoading} + isLoading={isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -247,7 +301,7 @@ export function ReviewWorkflowsCreateView() { /> - {isLoadingModels ? ( + {isLoading ? ( {formatMessage({ id: 'Settings.review-workflows.page.isLoading', @@ -256,10 +310,7 @@ export function ReviewWorkflowsCreateView() { ) : ( - + )} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js index e86286af07..d7c1e94a77 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js @@ -16,11 +16,19 @@ import { useMutation } from 'react-query'; import { useSelector, useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors'; import { useLicenseLimits } from '../../../../../../hooks'; -import { resetWorkflow, setWorkflow } from '../../actions'; +import { + resetWorkflow, + setIsLoading, + setWorkflow, + setContentTypes, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -31,7 +39,15 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectHasDeletedServerStages, + selectIsLoading, + selectRoles, + selectServerState, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsEditView() { @@ -42,29 +58,22 @@ export function ReviewWorkflowsEditView() { const { put } = useFetchClient(); const { formatAPIError } = useAPIErrorHandler(); const toggleNotification = useNotification(); - const { - isLoading: isWorkflowLoading, - meta, - workflows, - status: workflowStatus, - refetch, - } = useReviewWorkflows(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { - status, - clientState: { - currentWorkflow: { - data: currentWorkflow, - isDirty: currentWorkflowIsDirty, - hasDeletedServerStages, - }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { isLoading: isLoadingWorkflow, meta, workflows, refetch } = useReviewWorkflows(); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const serverState = useSelector(selectServerState); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const hasDeletedServerStages = useSelector(selectHasDeletedServerStages); + const roles = useSelector(selectRoles); + const isLoading = useSelector(selectIsLoading); const { allowedActions: { canDelete, canUpdate }, } = useRBAC(permissions.settings['review-workflows']); const [savePrompts, setSavePrompts] = React.useState({}); const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); const [showLimitModal, setShowLimitModal] = React.useState(false); const [initialErrors, setInitialErrors] = React.useState(null); @@ -73,7 +82,7 @@ export function ReviewWorkflowsEditView() { .filter((workflow) => workflow.id !== parseInt(workflowId, 10)) .flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -98,7 +107,29 @@ export function ReviewWorkflowsEditView() { setInitialErrors(null); try { - const res = await mutateAsync({ workflow }); + const res = await mutateAsync({ + workflow: { + ...workflow, + + // compare permissions of stages and only submit the ones which have + // changed; this enables partial updates e.g. for users who don't have + // permissions to see roles + stages: workflow.stages.map((stage) => { + const hasUpdatedPermissions = (stage.permissions ?? []).some( + ({ permission: { role } }) => + !serverState.workflow.stages.find( + (stage) => + !!(stage.permissions ?? []).find((permission) => permission.role === role) + ) + ); + + return { + ...stage, + permissions: hasUpdatedPermissions ? stage.permissions : undefined, + }; + }), + }, + }); return res; } catch (error) { @@ -194,14 +225,37 @@ export function ReviewWorkflowsEditView() { const limits = getFeature('review-workflows'); React.useEffect(() => { - dispatch(setWorkflow({ status: workflowStatus, data: workflow })); + if (!isLoadingWorkflow) { + dispatch(setWorkflow({ workflow })); + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingWorkflow || isLoadingContentTypes || isLoadingRoles)); // reset the state to the initial state to avoid flashes if a user // navigates from an edit-view to a create-view return () => { dispatch(resetWorkflow()); }; - }, [workflowStatus, workflow, dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingWorkflow, + isLoadingRoles, + serverRoles, + singleTypes, + workflow, + workflows, + ]); /** * If the current license has a limit: @@ -217,7 +271,7 @@ export function ReviewWorkflowsEditView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowCount > parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -234,12 +288,27 @@ export function ReviewWorkflowsEditView() { }, [ currentWorkflow.stages.length, isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowCount, meta.workflowsTotal, ]); + React.useEffect(() => { + const filteredRoles = roles.filter((role) => role.code !== 'strapi-super-admin'); + + if (!isLoading && filteredRoles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + // TODO: redirect back to list-view if workflow is not found? return ( @@ -259,7 +328,7 @@ export function ReviewWorkflowsEditView() { disabled={!currentWorkflowIsDirty} // if the confirm dialog is open the loading state is on // the confirm button already - loading={!Object.keys(savePrompts).length > 0 && isLoading} + loading={!Object.keys(savePrompts).length > 0 && isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -269,7 +338,7 @@ export function ReviewWorkflowsEditView() { ) } subtitle={ - currentWorkflow.stages.length > 0 && + !isLoadingWorkflow && formatMessage( { id: 'Settings.review-workflows.page.subtitle', @@ -282,7 +351,7 @@ export function ReviewWorkflowsEditView() { /> - {isLoadingModels || status === 'loading' ? ( + {isLoading ? ( {formatMessage({ @@ -293,12 +362,7 @@ export function ReviewWorkflowsEditView() { ) : ( - + ({ ...stage, // A safety net in case a stage does not have a color assigned; - // this normallly should not happen + // this should not happen color: stage?.color ?? STAGE_COLOR_DEFAULT, })), }; } + break; + } - draft.clientState.currentWorkflow.hasDeletedServerStages = false; + case ACTION_SET_WORKFLOWS: { + draft.serverState.workflows = payload; break; } @@ -71,12 +95,6 @@ export function reducer(state = initialState, action) { (stage) => (stage?.id ?? stage.__temp_key__) !== stageId ); - if (!currentWorkflow.hasDeletedServerStages) { - draft.clientState.currentWorkflow.hasDeletedServerStages = !!( - state.serverState.workflow?.stages ?? [] - ).find((stage) => stage.id === stageId); - } - break; } @@ -149,16 +167,6 @@ export function reducer(state = initialState, action) { default: break; } - - if (state.clientState.currentWorkflow.data && draft.serverState.workflow) { - draft.clientState.currentWorkflow.isDirty = !isEqual( - current(draft.clientState.currentWorkflow).data, - draft.serverState.workflow - ); - } else { - // if there is no workflow on the server, the workflow is awalys considered dirty - draft.clientState.currentWorkflow.isDirty = true; - } }); } diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js index 1c4d4d84f5..09f059d08f 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js @@ -3,6 +3,9 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, @@ -33,10 +36,57 @@ describe('Admin | Settings | Review Workflows | reducer', () => { state = initialState; }); + test('ACTION_SET_IS_LOADING', () => { + const action = { + type: ACTION_SET_IS_LOADING, + payload: true, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + isLoading: true, + }), + }) + ); + }); + + test('ACTION_SET_CONTENT_TYPES', () => { + const action = { + type: ACTION_SET_CONTENT_TYPES, + payload: { collectionTypes: [{ id: 1 }] }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + contentTypes: { + collectionTypes: [{ id: 1 }], + }, + }), + }) + ); + }); + + test('ACTION_SET_ROLES', () => { + const action = { + type: ACTION_SET_ROLES, + payload: [{ id: 1 }], + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + roles: [{ id: 1 }], + }), + }) + ); + }); + test('ACTION_SET_WORKFLOW with workflows', () => { const action = { type: ACTION_SET_WORKFLOW, - payload: { status: 'loading-state', workflow: WORKFLOW_FIXTURE }, + payload: WORKFLOW_FIXTURE, }; const DEFAULT_WORKFLOW_FIXTURE = { @@ -51,15 +101,12 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect(reducer(state, action)).toStrictEqual( expect.objectContaining({ - status: 'loading-state', serverState: expect.objectContaining({ workflow: WORKFLOW_FIXTURE, }), clientState: expect.objectContaining({ currentWorkflow: expect.objectContaining({ data: DEFAULT_WORKFLOW_FIXTURE, - isDirty: false, - hasDeletedServerStages: false, }), }), }) @@ -78,7 +125,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { workflow: WORKFLOW_FIXTURE, }, clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -95,107 +142,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to true if stageId exists on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to false if stageId does not exist on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: { - ...WORKFLOW_FIXTURE, - stages: [...WORKFLOW_FIXTURE.stages, { __temp_key__: 3, name: 'something' }], - }, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: false, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - keep hasDeletedServerStages true as soon as one server stage has been deleted', () => { - const actionDeleteServerStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - const actionDeleteClientStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - state = reducer(state, actionDeleteServerStage); - state = reducer(state, actionDeleteClientStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - test('ACTION_ADD_STAGE', () => { const action = { type: ACTION_ADD_STAGE, @@ -206,7 +152,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -239,7 +185,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: null, isDirty: false }, + currentWorkflow: { data: null }, }, }; @@ -287,7 +233,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -320,7 +266,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -343,52 +289,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('properly compare serverState and clientState and set isDirty accordingly', () => { - const actionAddStage = { - type: ACTION_ADD_STAGE, - payload: { name: 'something' }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, - }, - }; - - state = reducer(state, actionAddStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: true, - }), - }), - }) - ); - - const actionDeleteStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = reducer(state, actionDeleteStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: false, - }), - }), - }) - ); - }); - test('ACTION_UPDATE_STAGE_POSITION', () => { const action = { type: ACTION_UPDATE_STAGE_POSITION, @@ -403,7 +303,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -418,7 +317,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-1' }), ], }), - isDirty: true, }), }), }) @@ -439,7 +337,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -454,7 +351,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -475,7 +371,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -490,7 +385,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -511,7 +405,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -523,7 +416,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { data: expect.objectContaining({ name: 'test', }), - isDirty: true, }), }), }) @@ -543,7 +435,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -556,7 +447,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { name: '', stages: [], }), - isDirty: true, }), }), }) diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js new file mode 100644 index 0000000000..a65581e83a --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js @@ -0,0 +1,45 @@ +import isEqual from 'lodash/isEqual'; +import { createSelector } from 'reselect'; + +import { REDUX_NAMESPACE } from './constants'; +import { initialState } from './reducer'; + +export const selectNamespace = (state) => state[REDUX_NAMESPACE] ?? initialState; + +export const selectContentTypes = createSelector( + selectNamespace, + ({ serverState: { contentTypes } }) => contentTypes +); + +export const selectRoles = createSelector(selectNamespace, ({ serverState: { roles } }) => roles); + +export const selectCurrentWorkflow = createSelector( + selectNamespace, + ({ clientState: { currentWorkflow } }) => currentWorkflow.data +); + +export const selectWorkflows = createSelector( + selectNamespace, + ({ serverState: { workflows } }) => workflows +); + +export const selectIsWorkflowDirty = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !isEqual(serverState.workflow, currentWorkflow.data) +); + +export const selectHasDeletedServerStages = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !(serverState.workflow?.stages ?? []).every( + (stage) => !!currentWorkflow.data.stages.find(({ id }) => id === stage.id) + ) +); + +export const selectIsLoading = createSelector( + selectNamespace, + ({ clientState: { isLoading } }) => isLoading +); + +export const selectServerState = createSelector(selectNamespace, ({ serverState }) => serverState); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js index 9fe921b9da..f7c65a84d1 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js @@ -145,4 +145,79 @@ describe('Settings | Review Workflows | validateWorkflow()', () => { } `); }); + + test('stages.permissions: array', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [{ role: 1, action: 'admin::review-workflow.stage.transition' }], + }, + ], + }) + ).toEqual(true); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [], + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "Must be either an array or undefined", + }, + ], + } + `); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: { role: '1', action: 'admin::review-workflow.stage.transition' }, + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "stages[0].permissions must be a \`array\` type, but the final value was: \`{ + "role": "\\"1\\"", + "action": "\\"admin::review-workflow.stage.transition\\"" + }\`.", + }, + ], + } + `); + }); + + test('stages.permissions: undefined', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: undefined, + }, + ], + }) + ).toEqual(true); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js index cdac8102ce..1c1fc22225 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js @@ -57,6 +57,33 @@ export async function validateWorkflow({ values, formatMessage }) { }) ) .matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), + + permissions: yup + .array( + yup.object({ + role: yup + .number() + .strict() + .typeError( + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions.role.number', + defaultMessage: 'Role must be of type number', + }) + ).required, + action: yup.string().required({ + id: 'Settings.review-workflows.validation.stage.permissions.action.required', + defaultMessage: 'Action is a required argument', + }), + }) + ) + .strict() + .min( + 1, + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions', + defaultMessage: 'Must be either an array or undefined', + }) + ), }) ) .min(1), From a5599246195faff382d28776d10add1bc4136c94 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 8 Aug 2023 12:43:54 +0200 Subject: [PATCH 20/32] Fix: Make return values of data-fetching hooks stable --- .../admin/src/hooks/useAdminRoles/index.js | 24 ++++++++++++------ .../src/hooks/useAdminUsers/useAdminUsers.js | 22 ++++++++++------ .../hooks/useContentTypes/useContentTypes.js | 25 +++++++++++++------ .../hooks/useReviewWorkflows.js | 23 +++++++++++------ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index a0ea4520aa..c934afbbc9 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useCollator, useFetchClient } from '@strapi/helper-plugin'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; @@ -22,16 +24,24 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { queryOptions ); - let roles = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const roles = React.useMemo(() => { + let roles = []; - if (id && data) { - roles = [data.data]; - } else if (Array.isArray(data?.data)) { - roles = data.data; - } + if (id && data) { + roles = [data.data]; + } else if (Array.isArray(data?.data)) { + roles = data.data; + } + + return [...roles].sort((a, b) => formatter.compare(a.name, b.name)); + }, [data, id, formatter]); return { - roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), + roles, error, isError, isLoading, diff --git a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js index a4ea385bb1..f477e1aa5e 100644 --- a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js +++ b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,17 +22,23 @@ export function useAdminUsers(params = {}, queryOptions = {}) { queryOptions ); - let users = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const users = React.useMemo(() => { + if (id && data) { + return [data]; + } else if (Array.isArray(data?.results)) { + return data.results; + } - if (id && data) { - users = [data]; - } else if (Array.isArray(data?.results)) { - users = data.results; - } + return []; + }, [data, id]); return { users, - pagination: data?.pagination ?? null, + pagination: React.useMemo(() => data?.pagination ?? null, [data?.pagination]), isLoading, isError, refetch, diff --git a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js index 52ba5fe4fb..1917f9bc69 100644 --- a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js +++ b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useAPIErrorHandler, useFetchClient, useNotification } from '@strapi/helper-plugin'; import { useQueries } from 'react-query'; @@ -29,16 +31,25 @@ export function useContentTypes() { const [components, contentTypes] = queries; const isLoading = components.isLoading || contentTypes.isLoading; - const collectionTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed - ); - const singleTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed - ); + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const collectionTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); + + const singleTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); return { isLoading, - components: components?.data ?? [], + components: React.useMemo(() => components?.data ?? [], [components?.data]), collectionTypes, singleTypes, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js index 2cc79fedb5..696dff44bf 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,18 +22,25 @@ export function useReviewWorkflows(params = {}) { } ); - let workflows = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks - if (id && data?.data) { - workflows = [data.data]; - } else if (Array.isArray(data?.data)) { - workflows = data.data; - } + const workflows = React.useMemo(() => { + if (id && data?.data) { + return [data.data]; + } else if (Array.isArray(data?.data)) { + return data.data; + } + + return []; + }, [data?.data, id]); return { // meta contains e.g. the total of all workflows. we can not use // the pagination object here, because the list is not paginated. - meta: data?.meta ?? {}, + meta: React.useMemo(() => data?.meta ?? {}, [data?.meta]), workflows, isLoading, status, From 785664e134cb879a032f35eb63bf2d86bc549b9d Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 8 Aug 2023 14:47:18 +0200 Subject: [PATCH 21/32] chore: clean getstarted index --- examples/getstarted/src/index.js | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/examples/getstarted/src/index.js b/examples/getstarted/src/index.js index 4329ac3747..5abfcc85cf 100644 --- a/examples/getstarted/src/index.js +++ b/examples/getstarted/src/index.js @@ -16,34 +16,7 @@ module.exports = { * This gives you an opportunity to set up your data model, * run jobs, or perform some special logic. */ - async bootstrap({ strapi }) { - const roleService = strapi.service(`admin::role`); - const permissionService = strapi.service(`admin::permission`); - - // TODO: Remove - only for testing - await roleService.assignPermissions(2, [ - { - action: 'admin::review-workflows.change-stage', - actionParameters: { from: 1, to: 2 }, - }, - ]); - - - const user = await strapi - .query('admin::user') - .findOne({ where: { id: 2 }, populate: ['roles'] }); - - if (!user || !(user.isActive === true)) { - return { authenticated: false }; - } - - const userAbility = await permissionService.engine.generateUserAbility(user); - - console.log(userAbility.can({ - name: 'admin::review-workflows.change-stage', - params: { from: 1, to: 2 } - }, 'all')); - }, + bootstrap({ strapi }) {}, /** * An asynchronous destroy function that runs before From 795bea005b9c22fc782ad68c7d275b5ac2903fda Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 1 Aug 2023 14:28:44 +0200 Subject: [PATCH 22/32] Enhancement: Render permissions for each workflow stage --- .../admin/src/hooks/useAdminRoles/index.js | 2 +- .../useLicenseLimits/useLicenseLimits.js | 12 +- .../pages/ReviewWorkflows/actions/index.js | 39 ++- .../actions/tests/index.test.js | 79 ++++++- .../components/Stages/Stage/Stage.js | 112 ++++++++- .../Stages/Stage/tests/Stage.test.js | 214 ++++++++++++++--- .../components/Stages/tests/Stages.test.js | 88 +++---- .../WorkflowAttributes/WorkflowAttributes.js | 31 +-- .../tests/WorkflowAttributes.test.js | 198 ++++++++-------- .../pages/ReviewWorkflows/constants.js | 4 + .../pages/CreateView/CreateView.js | 89 +++++-- .../pages/EditView/EditView.js | 137 ++++++++--- .../pages/ReviewWorkflows/reducer/index.js | 62 ++--- .../reducer/tests/index.test.js | 222 +++++------------- .../pages/ReviewWorkflows/selectors.js | 45 ++++ .../utils/tests/validateWorkflow.test.js | 75 ++++++ .../ReviewWorkflows/utils/validateWorkflow.js | 27 +++ 17 files changed, 963 insertions(+), 473 deletions(-) create mode 100644 packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index 86ffcda4dc..a0ea4520aa 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -31,7 +31,7 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { } return { - roles: roles.sort((a, b) => formatter.compare(a.name, b.name)), + roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), error, isError, isLoading, diff --git a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js index 774d49c9dc..60e690c5e3 100644 --- a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js +++ b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js @@ -3,7 +3,7 @@ import * as React from 'react'; import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; -export function useLicenseLimits({ enabled } = { enabled: true }) { +export function useLicenseLimits(queryOptions = {}) { const { get } = useFetchClient(); const { data, isError, isLoading } = useQuery( ['ee', 'license-limit-info'], @@ -15,11 +15,17 @@ export function useLicenseLimits({ enabled } = { enabled: true }) { return data; }, { - enabled, + ...queryOptions, + + // the request is expected to fail sometimes if a user does not + // have permissions + retry: false, } ); - const license = data ?? {}; + const license = React.useMemo(() => { + return data ?? {}; + }, [data]); const getFeature = React.useCallback( (name) => { diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js index 9ebb777b13..5711ee9924 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js @@ -2,19 +2,27 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, + ACTION_SET_WORKFLOWS, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, ACTION_UPDATE_WORKFLOW, } from '../constants'; -export function setWorkflow({ status, data }) { +export function setWorkflow({ workflow }) { return { type: ACTION_SET_WORKFLOW, - payload: { - status, - workflow: data, - }, + payload: workflow, + }; +} + +export function setWorkflows({ workflows }) { + return { + type: ACTION_SET_WORKFLOWS, + payload: workflows, }; } @@ -66,3 +74,24 @@ export function resetWorkflow() { type: ACTION_RESET_WORKFLOW, }; } + +export function setContentTypes(payload) { + return { + type: ACTION_SET_CONTENT_TYPES, + payload, + }; +} + +export function setRoles(payload) { + return { + type: ACTION_SET_ROLES, + payload, + }; +} + +export function setIsLoading(isLoading) { + return { + type: ACTION_SET_IS_LOADING, + payload: isLoading, + }; +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js index 0cb1ac3177..45e0e2699e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js @@ -1,19 +1,42 @@ -import { addStage, deleteStage, setWorkflow, updateStage } from '..'; +import { + addStage, + deleteStage, + setWorkflow, + setWorkflows, + updateStage, + updateStagePosition, + updateWorkflow, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, +} from '..'; import { ACTION_SET_WORKFLOW, ACTION_DELETE_STAGE, ACTION_ADD_STAGE, ACTION_UPDATE_STAGE, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, + ACTION_SET_WORKFLOWS, + ACTION_UPDATE_STAGE_POSITION, + ACTION_RESET_WORKFLOW, + ACTION_UPDATE_WORKFLOW, } from '../../constants'; describe('Admin | Settings | Review Workflow | actions', () => { test('setWorkflow()', () => { - expect(setWorkflow({ status: 'loading', data: null, something: 'else' })).toStrictEqual({ + expect(setWorkflow({ workflow: null, something: 'else' })).toStrictEqual({ type: ACTION_SET_WORKFLOW, - payload: { - status: 'loading', - workflow: null, - }, + payload: null, + }); + }); + + test('setWorkflows()', () => { + expect(setWorkflows({ workflows: [] })).toStrictEqual({ + type: ACTION_SET_WORKFLOWS, + payload: [], }); }); @@ -49,4 +72,48 @@ describe('Admin | Settings | Review Workflow | actions', () => { }, }); }); + + test('updateStagePosition()', () => { + expect(updateStagePosition(1, 2)).toStrictEqual({ + type: ACTION_UPDATE_STAGE_POSITION, + payload: { + newIndex: 2, + oldIndex: 1, + }, + }); + }); + + test('updateWorkflow()', () => { + expect(updateWorkflow({})).toStrictEqual({ + type: ACTION_UPDATE_WORKFLOW, + payload: {}, + }); + }); + + test('resetWorkflow()', () => { + expect(resetWorkflow()).toStrictEqual({ + type: ACTION_RESET_WORKFLOW, + }); + }); + + test('setContentTypes()', () => { + expect(setContentTypes({})).toStrictEqual({ + type: ACTION_SET_CONTENT_TYPES, + payload: {}, + }); + }); + + test('setRoles()', () => { + expect(setRoles({})).toStrictEqual({ + type: ACTION_SET_ROLES, + payload: {}, + }); + }); + + test('setIsLoading()', () => { + expect(setIsLoading(true)).toStrictEqual({ + type: ACTION_SET_IS_LOADING, + payload: true, + }); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js index efd000bc14..5d420eb95e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js @@ -9,25 +9,34 @@ import { Grid, GridItem, IconButton, + MultiSelect, + MultiSelectGroup, + MultiSelectOption, SingleSelect, SingleSelectOption, TextInput, VisuallyHidden, } from '@strapi/design-system'; -import { useTracking } from '@strapi/helper-plugin'; +import { NotAllowedInput, useTracking } from '@strapi/helper-plugin'; import { Drag, Trash } from '@strapi/icons'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; import { useDragAndDrop } from '../../../../../../../../../admin/src/content-manager/hooks'; import { composeRefs } from '../../../../../../../../../admin/src/content-manager/utils'; import { deleteStage, updateStage, updateStagePosition } from '../../../actions'; import { DRAG_DROP_TYPES } from '../../../constants'; +import { selectRoles } from '../../../selectors'; import { getAvailableStageColors, getStageColorByHex } from '../../../utils/colors'; +const NestedOption = styled(MultiSelectOption)` + padding-left: ${({ theme }) => theme.spaces[7]}; +`; + const AVAILABLE_COLORS = getAvailableStageColors(); function StageDropPreview() { @@ -144,6 +153,10 @@ export function Stage({ const [isOpen, setIsOpen] = React.useState(isOpenDefault); const [nameField, nameMeta, nameHelper] = useField(`stages.${index}.name`); const [colorField, colorMeta, colorHelper] = useField(`stages.${index}.color`); + const [permissionsField, permissionsMeta, permissionsHelper] = useField( + `stages.${index}.permissions` + ); + const roles = useSelector(selectRoles); const [{ handlerId, isDragging, handleKeyDown }, stageRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(canReorder, { index, @@ -171,12 +184,17 @@ export function Stage({ color: hex, })); + const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; + + const filteredRoles = roles + // Super admins always have permissions to do everything and therefore + // there is no point for this role to show up in the role combobox + .filter((role) => role.code !== 'strapi-super-admin'); + React.useEffect(() => { dragPreviewRef(getEmptyImage(), { captureDraggingState: false }); }, [dragPreviewRef, index]); - const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; - return ( {liveText && {liveText}} @@ -315,6 +333,92 @@ export function Stage({ })} + + + {filteredRoles.length === 0 ? ( + + ) : ( + { + // Because the select components expects strings for values, but + // the yup schema validates numbers are sent to the API, we have + // to coerce the string value back to a number + const nextValues = values.map((value) => ({ + role: parseInt(value, 10), + action: 'admin::review-workflows.stage.transition', + })); + + permissionsHelper.setValue(nextValues); + + dispatch(updateStage(id, { permissions: nextValues })); + }} + placeholder={formatMessage({ + id: 'Settings.review-workflows.stage.permissions.placeholder', + defaultMessage: 'Select a role', + })} + required + // The Select component expects strings for values + value={(permissionsField.value ?? []).map((permission) => `${permission.role}`)} + withTags + > + {[ + { + value: null, + label: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.allRoles.label', + defaultMessage: 'All roles', + }), + children: filteredRoles.map((role) => ({ + value: `${role.id}`, + label: role.name, + })), + }, + ].map((role) => { + if ('children' in role) { + return ( + child.value)} + > + {role.children.map((role) => { + return ( + + {role.label} + + ); + })} + + ); + } + + return ( + + {role.label} + + ); + })} + + )} + diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js index f31a7a8290..1ee2a8f7e6 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js @@ -1,16 +1,16 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../../admin/src/core/store/configureStore'; -import { STAGE_COLOR_DEFAULT } from '../../../../constants'; +import { REDUX_NAMESPACE, STAGE_COLOR_DEFAULT } from '../../../../constants'; import { reducer } from '../../../../reducer'; import { Stage } from '../Stage'; @@ -19,18 +19,86 @@ const STAGES_FIXTURE = { index: 0, }; +const WORKFLOWS_FIXTURE = [ + { + id: 1, + name: 'Default', + contentTypes: ['uid1'], + stages: [], + }, + + { + id: 2, + name: 'Default 2', + contentTypes: ['uid2'], + stages: [], + }, +]; + +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], +}; + +const ROLES_FIXTURE = [ + { + id: 1, + code: 'strapi-editor', + name: 'Editor', + }, + + { + id: 2, + code: 'strapi-author', + name: 'Author', + }, + + { + id: 3, + code: 'strapi-super-admin', + name: 'Super Admin', + }, +]; + const ComponentFixture = ({ // eslint-disable-next-line react/prop-types stages = [ { color: STAGE_COLOR_DEFAULT, name: 'something', + permissions: [{ role: 1, action: 'admin::review-workflows.stage.transition' }], }, ], ...props }) => { - const store = configureStore([], [reducer]); - const formik = useFormik({ enableReinitialize: true, initialValues: { @@ -40,21 +108,43 @@ const ComponentFixture = ({ }); return ( - - - - - - - - - - - + + + ); }; -const setup = (props) => render(); +const setup = ({ roles, ...props } = {}) => + render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: CONTENT_TYPES_FIXTURE, + roles: roles || ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }); const user = userEvent.setup(); @@ -72,11 +162,25 @@ describe('Admin | Settings | Review Workflow | Stage', () => { // does not have better identifiers await user.click(container.querySelector('button[aria-expanded]')); - expect(queryByRole('textbox')).toBeInTheDocument(); - expect(getByRole('textbox').value).toBe('something'); + // Expect the accordion header to have the same value as the textbox + expect(getByRole('button', { name: /something/i })); expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name'); - expect(getByRole('combobox')).toHaveTextContent('Blue'); + // Name + expect(queryByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox').value).toBe('something'); + + // Color combobox + await waitFor(() => + expect(getByRole('combobox', { name: /color/i })).toHaveTextContent('Blue') + ); + + // Permissions combobox + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toHaveTextContent('Editor') + ); expect( queryByRole('button', { @@ -88,27 +192,31 @@ describe('Admin | Settings | Review Workflow | Stage', () => { it('should open the accordion panel if isOpen = true', async () => { const { queryByRole } = setup({ isOpen: true }); - expect(queryByRole('textbox')).toBeInTheDocument(); + await waitFor(() => expect(queryByRole('textbox')).toBeInTheDocument()); }); it('should not render the delete button if canDelete=false', async () => { const { queryByRole } = setup({ isOpen: true, canDelete: false }); - expect( - queryByRole('button', { - name: /delete stage/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /delete stage/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not render delete drag button if canUpdate=false', async () => { const { queryByRole } = setup({ isOpen: true, canUpdate: false }); - expect( - queryByRole('button', { - name: /drag/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /drag/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not crash on a custom color code', async () => { @@ -131,7 +239,49 @@ describe('Admin | Settings | Review Workflow | Stage', () => { await user.click(container.querySelector('button[aria-expanded]')); + // Name expect(getByRole('textbox')).toHaveAttribute('disabled'); - expect(getByRole('combobox')).toHaveAttribute('data-disabled'); + + // Color + expect(getByRole('combobox', { name: /color/i })).toHaveAttribute('data-disabled'); + + // Permissions + expect(getByRole('combobox', { name: /roles that can change this stage/i })).toHaveAttribute( + 'data-disabled' + ); + }); + + it('should render a list of all available roles (except super admins)', async () => { + const { container, getByRole, queryByRole } = setup({ canUpdate: true }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toBeInTheDocument() + ); + + await user.click(getByRole('combobox', { name: /roles that can change this stage/i })); + + await waitFor(() => expect(getByRole('option', { name: /All roles/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Editor/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Author/i })).toBeInTheDocument()); + await waitFor(() => + expect(queryByRole('option', { name: /Super Admin/i })).not.toBeInTheDocument() + ); + }); + + it('should render a no permissions fallback, if no roles are available', async () => { + const { container, getByText } = setup({ + canUpdate: true, + roles: [...ROLES_FIXTURE].filter((role) => role.code === 'strapi-super-admin'), + }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect(getByText(/you don’t have the permission to see roles/i)).toBeInTheDocument() + ); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js index 07f6286491..9877245eed 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js @@ -1,9 +1,8 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { fireEvent, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; @@ -11,7 +10,7 @@ import { Provider } from 'react-redux'; import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; import * as actions from '../../../actions'; -import { ACTION_SET_WORKFLOW, STAGE_COLOR_DEFAULT } from '../../../constants'; +import { STAGE_COLOR_DEFAULT } from '../../../constants'; import { reducer } from '../../../reducer'; import { Stages } from '../Stages'; @@ -21,6 +20,15 @@ jest.mock('../../../actions', () => ({ ...jest.requireActual('../../../actions'), })); +// A single stage needs a formik provider, which is a bit complicated to setup. +// Since we don't want to test the single stages, but the overall composition +// it is the easiest for the test setup to just render an id instead of the +// whole component. +jest.mock('../Stage', () => ({ + __esModule: true, + Stage: ({ id }) => id, +})); + const STAGES_FIXTURE = [ { id: 1, @@ -35,44 +43,25 @@ const STAGES_FIXTURE = [ }, ]; -const WORKFLOWS_FIXTURE = [ - { - id: 1, - stages: STAGES_FIXTURE, - }, -]; +const setup = (props) => ({ + ...render(, { + wrapper({ children }) { + const store = configureStore([], [reducer]); -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); - - store.dispatch({ type: ACTION_SET_WORKFLOW, payload: { workflows: WORKFLOWS_FIXTURE } }); - - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - stages: STAGES_FIXTURE, + return ( + + + + {children} + + + + ); }, - validateOnChange: false, - }); + }), - return ( - - - - - - - - - - - - ); -}; - -const setup = (props) => render(); - -const user = userEvent.setup(); + user: userEvent.setup(), +}); describe('Admin | Settings | Review Workflow | Stages', () => { beforeEach(() => { @@ -82,8 +71,8 @@ describe('Admin | Settings | Review Workflow | Stages', () => { it('should render a list of stages', () => { const { getByText } = setup(); - expect(getByText(STAGES_FIXTURE[0].name)).toBeInTheDocument(); - expect(getByText(STAGES_FIXTURE[1].name)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[0].id)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[1].id)).toBeInTheDocument(); }); it('should render a "add new stage" button', () => { @@ -93,7 +82,7 @@ describe('Admin | Settings | Review Workflow | Stages', () => { }); it('should append a new stage when clicking "add new stage"', async () => { - const { getByRole } = setup(); + const { getByRole, user } = setup(); const spy = jest.spyOn(actions, 'addStage'); await user.click( @@ -106,23 +95,6 @@ describe('Admin | Settings | Review Workflow | Stages', () => { expect(spy).toBeCalledWith({ name: '' }); }); - it('should update the name of a stage by changing the input value', async () => { - const { queryByRole, getByRole } = setup(); - const spy = jest.spyOn(actions, 'updateStage'); - - await user.click(getByRole('button', { name: /stage-2/i })); - - const input = queryByRole('textbox', { - name: /stage name/i, - }); - - fireEvent.change(input, { target: { value: 'New name' } }); - - expect(spy).toBeCalledWith(2, { - name: 'New name', - }); - }); - it('should not render the "add stage" button if canUpdate = false', () => { const { queryByText } = setup({ canUpdate: false }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js index 46f6c45f4e..af8f0e72c3 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js @@ -13,10 +13,11 @@ import { useCollator } from '@strapi/helper-plugin'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { updateWorkflow } from '../../actions'; +import { selectContentTypes, selectCurrentWorkflow, selectWorkflows } from '../../selectors'; const NestedOption = styled(MultiSelectOption)` padding-left: ${({ theme }) => theme.spaces[7]}; @@ -26,14 +27,12 @@ const ContentTypeTakeNotice = styled(Typography)` font-style: italic; `; -export function WorkflowAttributes({ - canUpdate, - contentTypes: { collectionTypes, singleTypes }, - currentWorkflow, - workflows, -}) { +export function WorkflowAttributes({ canUpdate }) { const { formatMessage, locale } = useIntl(); const dispatch = useDispatch(); + const { collectionTypes, singleTypes } = useSelector(selectContentTypes); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const workflows = useSelector(selectWorkflows); const [nameField, nameMeta, nameHelper] = useField('name'); const [contentTypesField, contentTypesMeta, contentTypesHelper] = useField('contentTypes'); const formatter = useCollator(locale, { @@ -97,7 +96,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.collectionTypes.label', defaultMessage: 'Collection Types', }), - children: collectionTypes + children: [...collectionTypes] .sort((a, b) => formatter.compare(a.info.displayName, b.info.displayName)) .map((contentType) => ({ label: contentType.info.displayName, @@ -114,7 +113,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.singleTypes.label', defaultMessage: 'Single Types', }), - children: singleTypes.map((contentType) => ({ + children: [...singleTypes].map((contentType) => ({ label: contentType.info.displayName, value: contentType.uid, })), @@ -178,24 +177,10 @@ export function WorkflowAttributes({ ); } -const ContentTypeType = PropTypes.shape({ - uid: PropTypes.string.isRequired, - info: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - }).isRequired, -}); - WorkflowAttributes.defaultProps = { canUpdate: true, - currentWorkflow: undefined, }; WorkflowAttributes.propTypes = { canUpdate: PropTypes.bool, - contentTypes: PropTypes.shape({ - collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - }).isRequired, - currentWorkflow: PropTypes.object, - workflows: PropTypes.array.isRequired, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js index 732e18a430..04edf9ac0b 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js @@ -8,31 +8,12 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; +import { REDUX_NAMESPACE } from '../../../constants'; import { reducer } from '../../../reducer'; import { WorkflowAttributes } from '../WorkflowAttributes'; -const CONTENT_TYPES_FIXTURE = { - collectionTypes: [ - { - uid: 'uid1', - info: { - displayName: 'Content Type 1', - }, - }, - ], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], -}; - const WORKFLOWS_FIXTURE = [ { id: 1, @@ -43,48 +24,63 @@ const WORKFLOWS_FIXTURE = [ { id: 2, - name: 'Workflow 1', - contentTypes: [], + name: 'Default 2', + contentTypes: ['uid2'], stages: [], }, ]; -const CURRENT_WORKFLOW_FIXTURE = { - ...WORKFLOWS_FIXTURE[0], +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], }; -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); +const ROLES_FIXTURE = []; +// eslint-disable-next-line react/prop-types +const ComponentFixture = ({ currentWorkflow, ...props } = {}) => { const formik = useFormik({ enableReinitialize: true, - initialValues: { - name: 'workflow name', - contentTypes: ['uid1', 'uid1'], - }, + initialValues: currentWorkflow || WORKFLOWS_FIXTURE[0], validateOnChange: false, }); return ( - - - - - - - - - - - + + + ); }; +// eslint-disable-next-line no-unused-vars const withMarkup = (query) => (text) => query((content, node) => { const hasText = (node) => node.textContent === text; @@ -93,8 +89,40 @@ const withMarkup = (query) => (text) => return hasText(node) && childrenDontHaveText; }); -const setup = (props) => ({ - ...render(), +const setup = ({ collectionTypes, singleTypes, currentWorkflow, ...props } = {}) => ({ + ...render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: { + collectionTypes: collectionTypes || CONTENT_TYPES_FIXTURE.collectionTypes, + singleTypes: singleTypes || CONTENT_TYPES_FIXTURE.singleTypes, + }, + roles: ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: currentWorkflow || WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }), user: userEvent.setup(), }); @@ -102,20 +130,18 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { it('should render values', async () => { const { getByRole, getByText, user } = setup(); - const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); - - expect(getByRole('textbox')).toHaveValue('workflow name'); - expect(getByText(/2 content types selected/i)).toBeInTheDocument(); + await waitFor(() => expect(getByText(/workflow name/i)).toBeInTheDocument()); + expect(getByRole('textbox', { name: /workflow name \*/i })).toHaveValue('Default'); + expect(getByText(/1 content type selected/i)).toBeInTheDocument(); expect(getByRole('textbox')).not.toHaveAttribute('disabled'); expect(getByRole('combobox', { name: /associated to/i })).not.toHaveAttribute('data-disabled'); - await user.click(contentTypesSelect); + await user.click(getByRole('combobox', { name: /associated to/i })); - await waitFor(() => { - expect(getByRole('option', { name: /content type 1/i })).toBeInTheDocument(); - expect(getByRole('option', { name: /content type 2/i })).toBeInTheDocument(); - }); + await waitFor(() => + expect(getByRole('option', { name: /Collection CT 1/i })).toBeInTheDocument() + ); }); it('should disabled fields if canUpdate = false', async () => { @@ -127,20 +153,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not collection-types', async () => { + it('should not render a collection-type group if there are no collection-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - }, + collectionTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -153,20 +168,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not single-types', async () => { + it('should not render a collection-type group if there are no single-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - - singleTypes: [], - }, + singleTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -188,23 +192,29 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render assigned content-types to the current workflow', async () => { + it('should not render the assigned content-types notice to the current workflow', async () => { const { getByRole, queryByText, user } = setup(); + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); + const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const queryByTextWithMarkup = withMarkup(queryByText); await user.click(contentTypesSelect); - await waitFor(() => { - expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument(); - }); + await waitFor(() => + expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument() + ); }); it('should render assigned content-types to the other workflows', async () => { - const { getByRole, getByText, user } = setup({ - currentWorkflow: { ...WORKFLOWS_FIXTURE[1] }, - }); + const { getByRole, getByText, user } = setup(); + + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const getByTextWithMarkup = withMarkup(getByText); @@ -212,11 +222,11 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); - it('should render assigned content-types to the other workflows, when currentWorkflow is not passed', async () => { + it('should render assigned content-types of other workflows, when currentWorkflow is not passed', async () => { const { getByRole, getByText, user } = setup({ currentWorkflow: undefined, }); @@ -227,7 +237,7 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js index aa63aa210b..742c843afa 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js @@ -3,7 +3,11 @@ import { lightTheme } from '@strapi/design-system'; export const REDUX_NAMESPACE = 'settings_review-workflows'; export const ACTION_RESET_WORKFLOW = `Settings/Review_Workflows/RESET_WORKFLOW`; +export const ACTION_SET_CONTENT_TYPES = `Settings/Review_Workflows/SET_CONTENT_TYPES`; +export const ACTION_SET_IS_LOADING = `Settings/Review_Workflows/SET_IS_LOADING`; +export const ACTION_SET_ROLES = `Settings/Review_Workflows/SET_ROLES`; export const ACTION_SET_WORKFLOW = `Settings/Review_Workflows/SET_WORKFLOW`; +export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`; export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`; export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`; export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js index 466fe26091..c439146a5d 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js @@ -15,10 +15,18 @@ import { useMutation } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { useLicenseLimits } from '../../../../../../hooks'; -import { addStage, resetWorkflow } from '../../actions'; +import { + addStage, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -29,7 +37,13 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsLoading, + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectRoles, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsCreateView() { @@ -39,13 +53,15 @@ export function ReviewWorkflowsCreateView() { const { formatAPIError } = useAPIErrorHandler(); const dispatch = useDispatch(); const toggleNotification = useNotification(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { isLoading: isWorkflowLoading, meta, workflows } = useReviewWorkflows(); - const { - clientState: { - currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const { isLoading: isLoadingWorkflow, meta, workflows } = useReviewWorkflows(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); + const isLoading = useSelector(selectIsLoading); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const roles = useSelector(selectRoles); const [showLimitModal, setShowLimitModal] = React.useState(false); const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits(); const [initialErrors, setInitialErrors] = React.useState(null); @@ -54,7 +70,7 @@ export function ReviewWorkflowsCreateView() { const limits = getFeature('review-workflows'); const contentTypesFromOtherWorkflows = workflows.flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -167,13 +183,36 @@ export function ReviewWorkflowsCreateView() { React.useEffect(() => { dispatch(resetWorkflow()); + if (!isLoadingWorkflow) { + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingContentTypes || isLoadingRoles)); + // Create an empty default stage dispatch( addStage({ name: '', }) ); - }, [dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingRoles, + isLoadingWorkflow, + serverRoles, + singleTypes, + workflows, + ]); /** * If the current license has a limit: @@ -189,7 +228,7 @@ export function ReviewWorkflowsCreateView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowsTotal >= parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -205,12 +244,27 @@ export function ReviewWorkflowsCreateView() { } }, [ isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowsTotal, currentWorkflow.stages.length, ]); + React.useEffect(() => { + const filteredRoles = roles.filter((role) => role.code !== 'strapi-super-admin'); + + if (!isLoading && filteredRoles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + return ( <> @@ -225,7 +279,7 @@ export function ReviewWorkflowsCreateView() { type="submit" size="M" disabled={!currentWorkflowIsDirty} - isLoading={isLoading} + isLoading={isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -247,7 +301,7 @@ export function ReviewWorkflowsCreateView() { /> - {isLoadingModels ? ( + {isLoading ? ( {formatMessage({ id: 'Settings.review-workflows.page.isLoading', @@ -256,10 +310,7 @@ export function ReviewWorkflowsCreateView() { ) : ( - + )} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js index e86286af07..351d07bc00 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js @@ -16,11 +16,19 @@ import { useMutation } from 'react-query'; import { useSelector, useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors'; import { useLicenseLimits } from '../../../../../../hooks'; -import { resetWorkflow, setWorkflow } from '../../actions'; +import { + resetWorkflow, + setIsLoading, + setWorkflow, + setContentTypes, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -31,7 +39,15 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectHasDeletedServerStages, + selectIsLoading, + selectRoles, + selectServerState, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsEditView() { @@ -42,29 +58,22 @@ export function ReviewWorkflowsEditView() { const { put } = useFetchClient(); const { formatAPIError } = useAPIErrorHandler(); const toggleNotification = useNotification(); - const { - isLoading: isWorkflowLoading, - meta, - workflows, - status: workflowStatus, - refetch, - } = useReviewWorkflows(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { - status, - clientState: { - currentWorkflow: { - data: currentWorkflow, - isDirty: currentWorkflowIsDirty, - hasDeletedServerStages, - }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { isLoading: isLoadingWorkflow, meta, workflows, refetch } = useReviewWorkflows(); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const serverState = useSelector(selectServerState); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const hasDeletedServerStages = useSelector(selectHasDeletedServerStages); + const roles = useSelector(selectRoles); + const isLoading = useSelector(selectIsLoading); const { allowedActions: { canDelete, canUpdate }, } = useRBAC(permissions.settings['review-workflows']); const [savePrompts, setSavePrompts] = React.useState({}); const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); const [showLimitModal, setShowLimitModal] = React.useState(false); const [initialErrors, setInitialErrors] = React.useState(null); @@ -73,7 +82,7 @@ export function ReviewWorkflowsEditView() { .filter((workflow) => workflow.id !== parseInt(workflowId, 10)) .flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -98,7 +107,32 @@ export function ReviewWorkflowsEditView() { setInitialErrors(null); try { - const res = await mutateAsync({ workflow }); + const res = await mutateAsync({ + workflow: { + ...workflow, + + // compare permissions of stages and only submit the ones which have + // changed; this enables partial updates e.g. for users who don't have + // permissions to see roles + stages: workflow.stages.map((stage) => { + const hasUpdatedPermissions = + stage?.permissions?.length > 0 + ? stage.permissions.some( + ({ role }) => + !serverState.workflow.stages.find( + (stage) => + !!(stage.permissions ?? []).find((permission) => permission.role === role) + ) + ) + : false; + + return { + ...stage, + permissions: hasUpdatedPermissions ? stage.permissions : undefined, + }; + }), + }, + }); return res; } catch (error) { @@ -194,14 +228,37 @@ export function ReviewWorkflowsEditView() { const limits = getFeature('review-workflows'); React.useEffect(() => { - dispatch(setWorkflow({ status: workflowStatus, data: workflow })); + if (!isLoadingWorkflow) { + dispatch(setWorkflow({ workflow })); + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingWorkflow || isLoadingContentTypes || isLoadingRoles)); // reset the state to the initial state to avoid flashes if a user // navigates from an edit-view to a create-view return () => { dispatch(resetWorkflow()); }; - }, [workflowStatus, workflow, dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingWorkflow, + isLoadingRoles, + serverRoles, + singleTypes, + workflow, + workflows, + ]); /** * If the current license has a limit: @@ -217,7 +274,7 @@ export function ReviewWorkflowsEditView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowCount > parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -234,12 +291,27 @@ export function ReviewWorkflowsEditView() { }, [ currentWorkflow.stages.length, isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowCount, meta.workflowsTotal, ]); + React.useEffect(() => { + const filteredRoles = roles.filter((role) => role.code !== 'strapi-super-admin'); + + if (!isLoading && filteredRoles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + // TODO: redirect back to list-view if workflow is not found? return ( @@ -259,7 +331,7 @@ export function ReviewWorkflowsEditView() { disabled={!currentWorkflowIsDirty} // if the confirm dialog is open the loading state is on // the confirm button already - loading={!Object.keys(savePrompts).length > 0 && isLoading} + loading={!Object.keys(savePrompts).length > 0 && isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -269,7 +341,7 @@ export function ReviewWorkflowsEditView() { ) } subtitle={ - currentWorkflow.stages.length > 0 && + !isLoadingWorkflow && formatMessage( { id: 'Settings.review-workflows.page.subtitle', @@ -282,7 +354,7 @@ export function ReviewWorkflowsEditView() { /> - {isLoadingModels || status === 'loading' ? ( + {isLoading ? ( {formatMessage({ @@ -293,12 +365,7 @@ export function ReviewWorkflowsEditView() { ) : ( - + ({ ...stage, // A safety net in case a stage does not have a color assigned; - // this normallly should not happen + // this should not happen color: stage?.color ?? STAGE_COLOR_DEFAULT, })), }; } + break; + } - draft.clientState.currentWorkflow.hasDeletedServerStages = false; + case ACTION_SET_WORKFLOWS: { + draft.serverState.workflows = payload; break; } case ACTION_RESET_WORKFLOW: { - draft.clientState.currentWorkflow.data = initialState.clientState.currentWorkflow.data; + draft.clientState = initialState.clientState; draft.serverState = initialState.serverState; break; } @@ -71,12 +95,6 @@ export function reducer(state = initialState, action) { (stage) => (stage?.id ?? stage.__temp_key__) !== stageId ); - if (!currentWorkflow.hasDeletedServerStages) { - draft.clientState.currentWorkflow.hasDeletedServerStages = !!( - state.serverState.workflow?.stages ?? [] - ).find((stage) => stage.id === stageId); - } - break; } @@ -149,16 +167,6 @@ export function reducer(state = initialState, action) { default: break; } - - if (state.clientState.currentWorkflow.data && draft.serverState.workflow) { - draft.clientState.currentWorkflow.isDirty = !isEqual( - current(draft.clientState.currentWorkflow).data, - draft.serverState.workflow - ); - } else { - // if there is no workflow on the server, the workflow is awalys considered dirty - draft.clientState.currentWorkflow.isDirty = true; - } }); } diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js index 1c4d4d84f5..09f059d08f 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js @@ -3,6 +3,9 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, @@ -33,10 +36,57 @@ describe('Admin | Settings | Review Workflows | reducer', () => { state = initialState; }); + test('ACTION_SET_IS_LOADING', () => { + const action = { + type: ACTION_SET_IS_LOADING, + payload: true, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + isLoading: true, + }), + }) + ); + }); + + test('ACTION_SET_CONTENT_TYPES', () => { + const action = { + type: ACTION_SET_CONTENT_TYPES, + payload: { collectionTypes: [{ id: 1 }] }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + contentTypes: { + collectionTypes: [{ id: 1 }], + }, + }), + }) + ); + }); + + test('ACTION_SET_ROLES', () => { + const action = { + type: ACTION_SET_ROLES, + payload: [{ id: 1 }], + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + roles: [{ id: 1 }], + }), + }) + ); + }); + test('ACTION_SET_WORKFLOW with workflows', () => { const action = { type: ACTION_SET_WORKFLOW, - payload: { status: 'loading-state', workflow: WORKFLOW_FIXTURE }, + payload: WORKFLOW_FIXTURE, }; const DEFAULT_WORKFLOW_FIXTURE = { @@ -51,15 +101,12 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect(reducer(state, action)).toStrictEqual( expect.objectContaining({ - status: 'loading-state', serverState: expect.objectContaining({ workflow: WORKFLOW_FIXTURE, }), clientState: expect.objectContaining({ currentWorkflow: expect.objectContaining({ data: DEFAULT_WORKFLOW_FIXTURE, - isDirty: false, - hasDeletedServerStages: false, }), }), }) @@ -78,7 +125,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { workflow: WORKFLOW_FIXTURE, }, clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -95,107 +142,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to true if stageId exists on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to false if stageId does not exist on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: { - ...WORKFLOW_FIXTURE, - stages: [...WORKFLOW_FIXTURE.stages, { __temp_key__: 3, name: 'something' }], - }, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: false, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - keep hasDeletedServerStages true as soon as one server stage has been deleted', () => { - const actionDeleteServerStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - const actionDeleteClientStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - state = reducer(state, actionDeleteServerStage); - state = reducer(state, actionDeleteClientStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - test('ACTION_ADD_STAGE', () => { const action = { type: ACTION_ADD_STAGE, @@ -206,7 +152,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -239,7 +185,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: null, isDirty: false }, + currentWorkflow: { data: null }, }, }; @@ -287,7 +233,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -320,7 +266,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -343,52 +289,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('properly compare serverState and clientState and set isDirty accordingly', () => { - const actionAddStage = { - type: ACTION_ADD_STAGE, - payload: { name: 'something' }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, - }, - }; - - state = reducer(state, actionAddStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: true, - }), - }), - }) - ); - - const actionDeleteStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = reducer(state, actionDeleteStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: false, - }), - }), - }) - ); - }); - test('ACTION_UPDATE_STAGE_POSITION', () => { const action = { type: ACTION_UPDATE_STAGE_POSITION, @@ -403,7 +303,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -418,7 +317,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-1' }), ], }), - isDirty: true, }), }), }) @@ -439,7 +337,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -454,7 +351,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -475,7 +371,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -490,7 +385,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -511,7 +405,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -523,7 +416,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { data: expect.objectContaining({ name: 'test', }), - isDirty: true, }), }), }) @@ -543,7 +435,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -556,7 +447,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { name: '', stages: [], }), - isDirty: true, }), }), }) diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js new file mode 100644 index 0000000000..a65581e83a --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js @@ -0,0 +1,45 @@ +import isEqual from 'lodash/isEqual'; +import { createSelector } from 'reselect'; + +import { REDUX_NAMESPACE } from './constants'; +import { initialState } from './reducer'; + +export const selectNamespace = (state) => state[REDUX_NAMESPACE] ?? initialState; + +export const selectContentTypes = createSelector( + selectNamespace, + ({ serverState: { contentTypes } }) => contentTypes +); + +export const selectRoles = createSelector(selectNamespace, ({ serverState: { roles } }) => roles); + +export const selectCurrentWorkflow = createSelector( + selectNamespace, + ({ clientState: { currentWorkflow } }) => currentWorkflow.data +); + +export const selectWorkflows = createSelector( + selectNamespace, + ({ serverState: { workflows } }) => workflows +); + +export const selectIsWorkflowDirty = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !isEqual(serverState.workflow, currentWorkflow.data) +); + +export const selectHasDeletedServerStages = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !(serverState.workflow?.stages ?? []).every( + (stage) => !!currentWorkflow.data.stages.find(({ id }) => id === stage.id) + ) +); + +export const selectIsLoading = createSelector( + selectNamespace, + ({ clientState: { isLoading } }) => isLoading +); + +export const selectServerState = createSelector(selectNamespace, ({ serverState }) => serverState); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js index 9fe921b9da..f7c65a84d1 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js @@ -145,4 +145,79 @@ describe('Settings | Review Workflows | validateWorkflow()', () => { } `); }); + + test('stages.permissions: array', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [{ role: 1, action: 'admin::review-workflow.stage.transition' }], + }, + ], + }) + ).toEqual(true); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [], + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "Must be either an array or undefined", + }, + ], + } + `); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: { role: '1', action: 'admin::review-workflow.stage.transition' }, + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "stages[0].permissions must be a \`array\` type, but the final value was: \`{ + "role": "\\"1\\"", + "action": "\\"admin::review-workflow.stage.transition\\"" + }\`.", + }, + ], + } + `); + }); + + test('stages.permissions: undefined', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: undefined, + }, + ], + }) + ).toEqual(true); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js index cdac8102ce..1c1fc22225 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js @@ -57,6 +57,33 @@ export async function validateWorkflow({ values, formatMessage }) { }) ) .matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), + + permissions: yup + .array( + yup.object({ + role: yup + .number() + .strict() + .typeError( + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions.role.number', + defaultMessage: 'Role must be of type number', + }) + ).required, + action: yup.string().required({ + id: 'Settings.review-workflows.validation.stage.permissions.action.required', + defaultMessage: 'Action is a required argument', + }), + }) + ) + .strict() + .min( + 1, + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions', + defaultMessage: 'Must be either an array or undefined', + }) + ), }) ) .min(1), From 6bbc43e3062887edb931cac155fd7720db5e4a19 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 8 Aug 2023 12:43:54 +0200 Subject: [PATCH 23/32] Fix: Make return values of data-fetching hooks stable --- .../admin/src/hooks/useAdminRoles/index.js | 24 ++++++++++++------ .../src/hooks/useAdminUsers/useAdminUsers.js | 22 ++++++++++------ .../hooks/useContentTypes/useContentTypes.js | 25 +++++++++++++------ .../hooks/useReviewWorkflows.js | 23 +++++++++++------ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index a0ea4520aa..c934afbbc9 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useCollator, useFetchClient } from '@strapi/helper-plugin'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; @@ -22,16 +24,24 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { queryOptions ); - let roles = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const roles = React.useMemo(() => { + let roles = []; - if (id && data) { - roles = [data.data]; - } else if (Array.isArray(data?.data)) { - roles = data.data; - } + if (id && data) { + roles = [data.data]; + } else if (Array.isArray(data?.data)) { + roles = data.data; + } + + return [...roles].sort((a, b) => formatter.compare(a.name, b.name)); + }, [data, id, formatter]); return { - roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), + roles, error, isError, isLoading, diff --git a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js index a4ea385bb1..f477e1aa5e 100644 --- a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js +++ b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,17 +22,23 @@ export function useAdminUsers(params = {}, queryOptions = {}) { queryOptions ); - let users = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const users = React.useMemo(() => { + if (id && data) { + return [data]; + } else if (Array.isArray(data?.results)) { + return data.results; + } - if (id && data) { - users = [data]; - } else if (Array.isArray(data?.results)) { - users = data.results; - } + return []; + }, [data, id]); return { users, - pagination: data?.pagination ?? null, + pagination: React.useMemo(() => data?.pagination ?? null, [data?.pagination]), isLoading, isError, refetch, diff --git a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js index 52ba5fe4fb..1917f9bc69 100644 --- a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js +++ b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useAPIErrorHandler, useFetchClient, useNotification } from '@strapi/helper-plugin'; import { useQueries } from 'react-query'; @@ -29,16 +31,25 @@ export function useContentTypes() { const [components, contentTypes] = queries; const isLoading = components.isLoading || contentTypes.isLoading; - const collectionTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed - ); - const singleTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed - ); + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const collectionTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); + + const singleTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); return { isLoading, - components: components?.data ?? [], + components: React.useMemo(() => components?.data ?? [], [components?.data]), collectionTypes, singleTypes, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js index 2cc79fedb5..696dff44bf 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,18 +22,25 @@ export function useReviewWorkflows(params = {}) { } ); - let workflows = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks - if (id && data?.data) { - workflows = [data.data]; - } else if (Array.isArray(data?.data)) { - workflows = data.data; - } + const workflows = React.useMemo(() => { + if (id && data?.data) { + return [data.data]; + } else if (Array.isArray(data?.data)) { + return data.data; + } + + return []; + }, [data?.data, id]); return { // meta contains e.g. the total of all workflows. we can not use // the pagination object here, because the list is not paginated. - meta: data?.meta ?? {}, + meta: React.useMemo(() => data?.meta ?? {}, [data?.meta]), workflows, isLoading, status, From 1b7eb764a025153ca45098e73df6ba839a66b2d1 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 1 Aug 2023 14:28:44 +0200 Subject: [PATCH 24/32] Enhancement: Render permissions for each workflow stage --- .../admin/src/hooks/useAdminRoles/index.js | 2 +- .../useLicenseLimits/useLicenseLimits.js | 12 +- .../pages/ReviewWorkflows/actions/index.js | 39 ++- .../actions/tests/index.test.js | 79 ++++++- .../components/Stages/Stage/Stage.js | 112 ++++++++- .../Stages/Stage/tests/Stage.test.js | 214 ++++++++++++++--- .../components/Stages/tests/Stages.test.js | 88 +++---- .../WorkflowAttributes/WorkflowAttributes.js | 31 +-- .../tests/WorkflowAttributes.test.js | 198 ++++++++-------- .../pages/ReviewWorkflows/constants.js | 4 + .../pages/CreateView/CreateView.js | 89 +++++-- .../pages/EditView/EditView.js | 137 ++++++++--- .../pages/ReviewWorkflows/reducer/index.js | 62 ++--- .../reducer/tests/index.test.js | 222 +++++------------- .../pages/ReviewWorkflows/selectors.js | 45 ++++ .../utils/tests/validateWorkflow.test.js | 75 ++++++ .../ReviewWorkflows/utils/validateWorkflow.js | 27 +++ 17 files changed, 963 insertions(+), 473 deletions(-) create mode 100644 packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index 86ffcda4dc..a0ea4520aa 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -31,7 +31,7 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { } return { - roles: roles.sort((a, b) => formatter.compare(a.name, b.name)), + roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), error, isError, isLoading, diff --git a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js index 774d49c9dc..60e690c5e3 100644 --- a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js +++ b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js @@ -3,7 +3,7 @@ import * as React from 'react'; import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; -export function useLicenseLimits({ enabled } = { enabled: true }) { +export function useLicenseLimits(queryOptions = {}) { const { get } = useFetchClient(); const { data, isError, isLoading } = useQuery( ['ee', 'license-limit-info'], @@ -15,11 +15,17 @@ export function useLicenseLimits({ enabled } = { enabled: true }) { return data; }, { - enabled, + ...queryOptions, + + // the request is expected to fail sometimes if a user does not + // have permissions + retry: false, } ); - const license = data ?? {}; + const license = React.useMemo(() => { + return data ?? {}; + }, [data]); const getFeature = React.useCallback( (name) => { diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js index 9ebb777b13..5711ee9924 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js @@ -2,19 +2,27 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, + ACTION_SET_WORKFLOWS, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, ACTION_UPDATE_WORKFLOW, } from '../constants'; -export function setWorkflow({ status, data }) { +export function setWorkflow({ workflow }) { return { type: ACTION_SET_WORKFLOW, - payload: { - status, - workflow: data, - }, + payload: workflow, + }; +} + +export function setWorkflows({ workflows }) { + return { + type: ACTION_SET_WORKFLOWS, + payload: workflows, }; } @@ -66,3 +74,24 @@ export function resetWorkflow() { type: ACTION_RESET_WORKFLOW, }; } + +export function setContentTypes(payload) { + return { + type: ACTION_SET_CONTENT_TYPES, + payload, + }; +} + +export function setRoles(payload) { + return { + type: ACTION_SET_ROLES, + payload, + }; +} + +export function setIsLoading(isLoading) { + return { + type: ACTION_SET_IS_LOADING, + payload: isLoading, + }; +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js index 0cb1ac3177..45e0e2699e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js @@ -1,19 +1,42 @@ -import { addStage, deleteStage, setWorkflow, updateStage } from '..'; +import { + addStage, + deleteStage, + setWorkflow, + setWorkflows, + updateStage, + updateStagePosition, + updateWorkflow, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, +} from '..'; import { ACTION_SET_WORKFLOW, ACTION_DELETE_STAGE, ACTION_ADD_STAGE, ACTION_UPDATE_STAGE, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, + ACTION_SET_WORKFLOWS, + ACTION_UPDATE_STAGE_POSITION, + ACTION_RESET_WORKFLOW, + ACTION_UPDATE_WORKFLOW, } from '../../constants'; describe('Admin | Settings | Review Workflow | actions', () => { test('setWorkflow()', () => { - expect(setWorkflow({ status: 'loading', data: null, something: 'else' })).toStrictEqual({ + expect(setWorkflow({ workflow: null, something: 'else' })).toStrictEqual({ type: ACTION_SET_WORKFLOW, - payload: { - status: 'loading', - workflow: null, - }, + payload: null, + }); + }); + + test('setWorkflows()', () => { + expect(setWorkflows({ workflows: [] })).toStrictEqual({ + type: ACTION_SET_WORKFLOWS, + payload: [], }); }); @@ -49,4 +72,48 @@ describe('Admin | Settings | Review Workflow | actions', () => { }, }); }); + + test('updateStagePosition()', () => { + expect(updateStagePosition(1, 2)).toStrictEqual({ + type: ACTION_UPDATE_STAGE_POSITION, + payload: { + newIndex: 2, + oldIndex: 1, + }, + }); + }); + + test('updateWorkflow()', () => { + expect(updateWorkflow({})).toStrictEqual({ + type: ACTION_UPDATE_WORKFLOW, + payload: {}, + }); + }); + + test('resetWorkflow()', () => { + expect(resetWorkflow()).toStrictEqual({ + type: ACTION_RESET_WORKFLOW, + }); + }); + + test('setContentTypes()', () => { + expect(setContentTypes({})).toStrictEqual({ + type: ACTION_SET_CONTENT_TYPES, + payload: {}, + }); + }); + + test('setRoles()', () => { + expect(setRoles({})).toStrictEqual({ + type: ACTION_SET_ROLES, + payload: {}, + }); + }); + + test('setIsLoading()', () => { + expect(setIsLoading(true)).toStrictEqual({ + type: ACTION_SET_IS_LOADING, + payload: true, + }); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js index efd000bc14..5d420eb95e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js @@ -9,25 +9,34 @@ import { Grid, GridItem, IconButton, + MultiSelect, + MultiSelectGroup, + MultiSelectOption, SingleSelect, SingleSelectOption, TextInput, VisuallyHidden, } from '@strapi/design-system'; -import { useTracking } from '@strapi/helper-plugin'; +import { NotAllowedInput, useTracking } from '@strapi/helper-plugin'; import { Drag, Trash } from '@strapi/icons'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; import { useDragAndDrop } from '../../../../../../../../../admin/src/content-manager/hooks'; import { composeRefs } from '../../../../../../../../../admin/src/content-manager/utils'; import { deleteStage, updateStage, updateStagePosition } from '../../../actions'; import { DRAG_DROP_TYPES } from '../../../constants'; +import { selectRoles } from '../../../selectors'; import { getAvailableStageColors, getStageColorByHex } from '../../../utils/colors'; +const NestedOption = styled(MultiSelectOption)` + padding-left: ${({ theme }) => theme.spaces[7]}; +`; + const AVAILABLE_COLORS = getAvailableStageColors(); function StageDropPreview() { @@ -144,6 +153,10 @@ export function Stage({ const [isOpen, setIsOpen] = React.useState(isOpenDefault); const [nameField, nameMeta, nameHelper] = useField(`stages.${index}.name`); const [colorField, colorMeta, colorHelper] = useField(`stages.${index}.color`); + const [permissionsField, permissionsMeta, permissionsHelper] = useField( + `stages.${index}.permissions` + ); + const roles = useSelector(selectRoles); const [{ handlerId, isDragging, handleKeyDown }, stageRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(canReorder, { index, @@ -171,12 +184,17 @@ export function Stage({ color: hex, })); + const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; + + const filteredRoles = roles + // Super admins always have permissions to do everything and therefore + // there is no point for this role to show up in the role combobox + .filter((role) => role.code !== 'strapi-super-admin'); + React.useEffect(() => { dragPreviewRef(getEmptyImage(), { captureDraggingState: false }); }, [dragPreviewRef, index]); - const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; - return ( {liveText && {liveText}} @@ -315,6 +333,92 @@ export function Stage({ })} + + + {filteredRoles.length === 0 ? ( + + ) : ( + { + // Because the select components expects strings for values, but + // the yup schema validates numbers are sent to the API, we have + // to coerce the string value back to a number + const nextValues = values.map((value) => ({ + role: parseInt(value, 10), + action: 'admin::review-workflows.stage.transition', + })); + + permissionsHelper.setValue(nextValues); + + dispatch(updateStage(id, { permissions: nextValues })); + }} + placeholder={formatMessage({ + id: 'Settings.review-workflows.stage.permissions.placeholder', + defaultMessage: 'Select a role', + })} + required + // The Select component expects strings for values + value={(permissionsField.value ?? []).map((permission) => `${permission.role}`)} + withTags + > + {[ + { + value: null, + label: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.allRoles.label', + defaultMessage: 'All roles', + }), + children: filteredRoles.map((role) => ({ + value: `${role.id}`, + label: role.name, + })), + }, + ].map((role) => { + if ('children' in role) { + return ( + child.value)} + > + {role.children.map((role) => { + return ( + + {role.label} + + ); + })} + + ); + } + + return ( + + {role.label} + + ); + })} + + )} + diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js index f31a7a8290..1ee2a8f7e6 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js @@ -1,16 +1,16 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../../admin/src/core/store/configureStore'; -import { STAGE_COLOR_DEFAULT } from '../../../../constants'; +import { REDUX_NAMESPACE, STAGE_COLOR_DEFAULT } from '../../../../constants'; import { reducer } from '../../../../reducer'; import { Stage } from '../Stage'; @@ -19,18 +19,86 @@ const STAGES_FIXTURE = { index: 0, }; +const WORKFLOWS_FIXTURE = [ + { + id: 1, + name: 'Default', + contentTypes: ['uid1'], + stages: [], + }, + + { + id: 2, + name: 'Default 2', + contentTypes: ['uid2'], + stages: [], + }, +]; + +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], +}; + +const ROLES_FIXTURE = [ + { + id: 1, + code: 'strapi-editor', + name: 'Editor', + }, + + { + id: 2, + code: 'strapi-author', + name: 'Author', + }, + + { + id: 3, + code: 'strapi-super-admin', + name: 'Super Admin', + }, +]; + const ComponentFixture = ({ // eslint-disable-next-line react/prop-types stages = [ { color: STAGE_COLOR_DEFAULT, name: 'something', + permissions: [{ role: 1, action: 'admin::review-workflows.stage.transition' }], }, ], ...props }) => { - const store = configureStore([], [reducer]); - const formik = useFormik({ enableReinitialize: true, initialValues: { @@ -40,21 +108,43 @@ const ComponentFixture = ({ }); return ( - - - - - - - - - - - + + + ); }; -const setup = (props) => render(); +const setup = ({ roles, ...props } = {}) => + render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: CONTENT_TYPES_FIXTURE, + roles: roles || ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }); const user = userEvent.setup(); @@ -72,11 +162,25 @@ describe('Admin | Settings | Review Workflow | Stage', () => { // does not have better identifiers await user.click(container.querySelector('button[aria-expanded]')); - expect(queryByRole('textbox')).toBeInTheDocument(); - expect(getByRole('textbox').value).toBe('something'); + // Expect the accordion header to have the same value as the textbox + expect(getByRole('button', { name: /something/i })); expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name'); - expect(getByRole('combobox')).toHaveTextContent('Blue'); + // Name + expect(queryByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox').value).toBe('something'); + + // Color combobox + await waitFor(() => + expect(getByRole('combobox', { name: /color/i })).toHaveTextContent('Blue') + ); + + // Permissions combobox + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toHaveTextContent('Editor') + ); expect( queryByRole('button', { @@ -88,27 +192,31 @@ describe('Admin | Settings | Review Workflow | Stage', () => { it('should open the accordion panel if isOpen = true', async () => { const { queryByRole } = setup({ isOpen: true }); - expect(queryByRole('textbox')).toBeInTheDocument(); + await waitFor(() => expect(queryByRole('textbox')).toBeInTheDocument()); }); it('should not render the delete button if canDelete=false', async () => { const { queryByRole } = setup({ isOpen: true, canDelete: false }); - expect( - queryByRole('button', { - name: /delete stage/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /delete stage/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not render delete drag button if canUpdate=false', async () => { const { queryByRole } = setup({ isOpen: true, canUpdate: false }); - expect( - queryByRole('button', { - name: /drag/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /drag/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not crash on a custom color code', async () => { @@ -131,7 +239,49 @@ describe('Admin | Settings | Review Workflow | Stage', () => { await user.click(container.querySelector('button[aria-expanded]')); + // Name expect(getByRole('textbox')).toHaveAttribute('disabled'); - expect(getByRole('combobox')).toHaveAttribute('data-disabled'); + + // Color + expect(getByRole('combobox', { name: /color/i })).toHaveAttribute('data-disabled'); + + // Permissions + expect(getByRole('combobox', { name: /roles that can change this stage/i })).toHaveAttribute( + 'data-disabled' + ); + }); + + it('should render a list of all available roles (except super admins)', async () => { + const { container, getByRole, queryByRole } = setup({ canUpdate: true }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toBeInTheDocument() + ); + + await user.click(getByRole('combobox', { name: /roles that can change this stage/i })); + + await waitFor(() => expect(getByRole('option', { name: /All roles/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Editor/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Author/i })).toBeInTheDocument()); + await waitFor(() => + expect(queryByRole('option', { name: /Super Admin/i })).not.toBeInTheDocument() + ); + }); + + it('should render a no permissions fallback, if no roles are available', async () => { + const { container, getByText } = setup({ + canUpdate: true, + roles: [...ROLES_FIXTURE].filter((role) => role.code === 'strapi-super-admin'), + }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect(getByText(/you don’t have the permission to see roles/i)).toBeInTheDocument() + ); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js index 07f6286491..9877245eed 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js @@ -1,9 +1,8 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { fireEvent, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; @@ -11,7 +10,7 @@ import { Provider } from 'react-redux'; import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; import * as actions from '../../../actions'; -import { ACTION_SET_WORKFLOW, STAGE_COLOR_DEFAULT } from '../../../constants'; +import { STAGE_COLOR_DEFAULT } from '../../../constants'; import { reducer } from '../../../reducer'; import { Stages } from '../Stages'; @@ -21,6 +20,15 @@ jest.mock('../../../actions', () => ({ ...jest.requireActual('../../../actions'), })); +// A single stage needs a formik provider, which is a bit complicated to setup. +// Since we don't want to test the single stages, but the overall composition +// it is the easiest for the test setup to just render an id instead of the +// whole component. +jest.mock('../Stage', () => ({ + __esModule: true, + Stage: ({ id }) => id, +})); + const STAGES_FIXTURE = [ { id: 1, @@ -35,44 +43,25 @@ const STAGES_FIXTURE = [ }, ]; -const WORKFLOWS_FIXTURE = [ - { - id: 1, - stages: STAGES_FIXTURE, - }, -]; +const setup = (props) => ({ + ...render(, { + wrapper({ children }) { + const store = configureStore([], [reducer]); -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); - - store.dispatch({ type: ACTION_SET_WORKFLOW, payload: { workflows: WORKFLOWS_FIXTURE } }); - - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - stages: STAGES_FIXTURE, + return ( + + + + {children} + + + + ); }, - validateOnChange: false, - }); + }), - return ( - - - - - - - - - - - - ); -}; - -const setup = (props) => render(); - -const user = userEvent.setup(); + user: userEvent.setup(), +}); describe('Admin | Settings | Review Workflow | Stages', () => { beforeEach(() => { @@ -82,8 +71,8 @@ describe('Admin | Settings | Review Workflow | Stages', () => { it('should render a list of stages', () => { const { getByText } = setup(); - expect(getByText(STAGES_FIXTURE[0].name)).toBeInTheDocument(); - expect(getByText(STAGES_FIXTURE[1].name)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[0].id)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[1].id)).toBeInTheDocument(); }); it('should render a "add new stage" button', () => { @@ -93,7 +82,7 @@ describe('Admin | Settings | Review Workflow | Stages', () => { }); it('should append a new stage when clicking "add new stage"', async () => { - const { getByRole } = setup(); + const { getByRole, user } = setup(); const spy = jest.spyOn(actions, 'addStage'); await user.click( @@ -106,23 +95,6 @@ describe('Admin | Settings | Review Workflow | Stages', () => { expect(spy).toBeCalledWith({ name: '' }); }); - it('should update the name of a stage by changing the input value', async () => { - const { queryByRole, getByRole } = setup(); - const spy = jest.spyOn(actions, 'updateStage'); - - await user.click(getByRole('button', { name: /stage-2/i })); - - const input = queryByRole('textbox', { - name: /stage name/i, - }); - - fireEvent.change(input, { target: { value: 'New name' } }); - - expect(spy).toBeCalledWith(2, { - name: 'New name', - }); - }); - it('should not render the "add stage" button if canUpdate = false', () => { const { queryByText } = setup({ canUpdate: false }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js index 46f6c45f4e..af8f0e72c3 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js @@ -13,10 +13,11 @@ import { useCollator } from '@strapi/helper-plugin'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { updateWorkflow } from '../../actions'; +import { selectContentTypes, selectCurrentWorkflow, selectWorkflows } from '../../selectors'; const NestedOption = styled(MultiSelectOption)` padding-left: ${({ theme }) => theme.spaces[7]}; @@ -26,14 +27,12 @@ const ContentTypeTakeNotice = styled(Typography)` font-style: italic; `; -export function WorkflowAttributes({ - canUpdate, - contentTypes: { collectionTypes, singleTypes }, - currentWorkflow, - workflows, -}) { +export function WorkflowAttributes({ canUpdate }) { const { formatMessage, locale } = useIntl(); const dispatch = useDispatch(); + const { collectionTypes, singleTypes } = useSelector(selectContentTypes); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const workflows = useSelector(selectWorkflows); const [nameField, nameMeta, nameHelper] = useField('name'); const [contentTypesField, contentTypesMeta, contentTypesHelper] = useField('contentTypes'); const formatter = useCollator(locale, { @@ -97,7 +96,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.collectionTypes.label', defaultMessage: 'Collection Types', }), - children: collectionTypes + children: [...collectionTypes] .sort((a, b) => formatter.compare(a.info.displayName, b.info.displayName)) .map((contentType) => ({ label: contentType.info.displayName, @@ -114,7 +113,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.singleTypes.label', defaultMessage: 'Single Types', }), - children: singleTypes.map((contentType) => ({ + children: [...singleTypes].map((contentType) => ({ label: contentType.info.displayName, value: contentType.uid, })), @@ -178,24 +177,10 @@ export function WorkflowAttributes({ ); } -const ContentTypeType = PropTypes.shape({ - uid: PropTypes.string.isRequired, - info: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - }).isRequired, -}); - WorkflowAttributes.defaultProps = { canUpdate: true, - currentWorkflow: undefined, }; WorkflowAttributes.propTypes = { canUpdate: PropTypes.bool, - contentTypes: PropTypes.shape({ - collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - }).isRequired, - currentWorkflow: PropTypes.object, - workflows: PropTypes.array.isRequired, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js index 732e18a430..04edf9ac0b 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js @@ -8,31 +8,12 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; +import { REDUX_NAMESPACE } from '../../../constants'; import { reducer } from '../../../reducer'; import { WorkflowAttributes } from '../WorkflowAttributes'; -const CONTENT_TYPES_FIXTURE = { - collectionTypes: [ - { - uid: 'uid1', - info: { - displayName: 'Content Type 1', - }, - }, - ], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], -}; - const WORKFLOWS_FIXTURE = [ { id: 1, @@ -43,48 +24,63 @@ const WORKFLOWS_FIXTURE = [ { id: 2, - name: 'Workflow 1', - contentTypes: [], + name: 'Default 2', + contentTypes: ['uid2'], stages: [], }, ]; -const CURRENT_WORKFLOW_FIXTURE = { - ...WORKFLOWS_FIXTURE[0], +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], }; -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); +const ROLES_FIXTURE = []; +// eslint-disable-next-line react/prop-types +const ComponentFixture = ({ currentWorkflow, ...props } = {}) => { const formik = useFormik({ enableReinitialize: true, - initialValues: { - name: 'workflow name', - contentTypes: ['uid1', 'uid1'], - }, + initialValues: currentWorkflow || WORKFLOWS_FIXTURE[0], validateOnChange: false, }); return ( - - - - - - - - - - - + + + ); }; +// eslint-disable-next-line no-unused-vars const withMarkup = (query) => (text) => query((content, node) => { const hasText = (node) => node.textContent === text; @@ -93,8 +89,40 @@ const withMarkup = (query) => (text) => return hasText(node) && childrenDontHaveText; }); -const setup = (props) => ({ - ...render(), +const setup = ({ collectionTypes, singleTypes, currentWorkflow, ...props } = {}) => ({ + ...render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: { + collectionTypes: collectionTypes || CONTENT_TYPES_FIXTURE.collectionTypes, + singleTypes: singleTypes || CONTENT_TYPES_FIXTURE.singleTypes, + }, + roles: ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: currentWorkflow || WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }), user: userEvent.setup(), }); @@ -102,20 +130,18 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { it('should render values', async () => { const { getByRole, getByText, user } = setup(); - const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); - - expect(getByRole('textbox')).toHaveValue('workflow name'); - expect(getByText(/2 content types selected/i)).toBeInTheDocument(); + await waitFor(() => expect(getByText(/workflow name/i)).toBeInTheDocument()); + expect(getByRole('textbox', { name: /workflow name \*/i })).toHaveValue('Default'); + expect(getByText(/1 content type selected/i)).toBeInTheDocument(); expect(getByRole('textbox')).not.toHaveAttribute('disabled'); expect(getByRole('combobox', { name: /associated to/i })).not.toHaveAttribute('data-disabled'); - await user.click(contentTypesSelect); + await user.click(getByRole('combobox', { name: /associated to/i })); - await waitFor(() => { - expect(getByRole('option', { name: /content type 1/i })).toBeInTheDocument(); - expect(getByRole('option', { name: /content type 2/i })).toBeInTheDocument(); - }); + await waitFor(() => + expect(getByRole('option', { name: /Collection CT 1/i })).toBeInTheDocument() + ); }); it('should disabled fields if canUpdate = false', async () => { @@ -127,20 +153,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not collection-types', async () => { + it('should not render a collection-type group if there are no collection-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - }, + collectionTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -153,20 +168,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not single-types', async () => { + it('should not render a collection-type group if there are no single-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - - singleTypes: [], - }, + singleTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -188,23 +192,29 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render assigned content-types to the current workflow', async () => { + it('should not render the assigned content-types notice to the current workflow', async () => { const { getByRole, queryByText, user } = setup(); + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); + const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const queryByTextWithMarkup = withMarkup(queryByText); await user.click(contentTypesSelect); - await waitFor(() => { - expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument(); - }); + await waitFor(() => + expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument() + ); }); it('should render assigned content-types to the other workflows', async () => { - const { getByRole, getByText, user } = setup({ - currentWorkflow: { ...WORKFLOWS_FIXTURE[1] }, - }); + const { getByRole, getByText, user } = setup(); + + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const getByTextWithMarkup = withMarkup(getByText); @@ -212,11 +222,11 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); - it('should render assigned content-types to the other workflows, when currentWorkflow is not passed', async () => { + it('should render assigned content-types of other workflows, when currentWorkflow is not passed', async () => { const { getByRole, getByText, user } = setup({ currentWorkflow: undefined, }); @@ -227,7 +237,7 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js index aa63aa210b..742c843afa 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js @@ -3,7 +3,11 @@ import { lightTheme } from '@strapi/design-system'; export const REDUX_NAMESPACE = 'settings_review-workflows'; export const ACTION_RESET_WORKFLOW = `Settings/Review_Workflows/RESET_WORKFLOW`; +export const ACTION_SET_CONTENT_TYPES = `Settings/Review_Workflows/SET_CONTENT_TYPES`; +export const ACTION_SET_IS_LOADING = `Settings/Review_Workflows/SET_IS_LOADING`; +export const ACTION_SET_ROLES = `Settings/Review_Workflows/SET_ROLES`; export const ACTION_SET_WORKFLOW = `Settings/Review_Workflows/SET_WORKFLOW`; +export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`; export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`; export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`; export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js index 466fe26091..c439146a5d 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js @@ -15,10 +15,18 @@ import { useMutation } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { useLicenseLimits } from '../../../../../../hooks'; -import { addStage, resetWorkflow } from '../../actions'; +import { + addStage, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -29,7 +37,13 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsLoading, + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectRoles, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsCreateView() { @@ -39,13 +53,15 @@ export function ReviewWorkflowsCreateView() { const { formatAPIError } = useAPIErrorHandler(); const dispatch = useDispatch(); const toggleNotification = useNotification(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { isLoading: isWorkflowLoading, meta, workflows } = useReviewWorkflows(); - const { - clientState: { - currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const { isLoading: isLoadingWorkflow, meta, workflows } = useReviewWorkflows(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); + const isLoading = useSelector(selectIsLoading); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const roles = useSelector(selectRoles); const [showLimitModal, setShowLimitModal] = React.useState(false); const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits(); const [initialErrors, setInitialErrors] = React.useState(null); @@ -54,7 +70,7 @@ export function ReviewWorkflowsCreateView() { const limits = getFeature('review-workflows'); const contentTypesFromOtherWorkflows = workflows.flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -167,13 +183,36 @@ export function ReviewWorkflowsCreateView() { React.useEffect(() => { dispatch(resetWorkflow()); + if (!isLoadingWorkflow) { + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingContentTypes || isLoadingRoles)); + // Create an empty default stage dispatch( addStage({ name: '', }) ); - }, [dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingRoles, + isLoadingWorkflow, + serverRoles, + singleTypes, + workflows, + ]); /** * If the current license has a limit: @@ -189,7 +228,7 @@ export function ReviewWorkflowsCreateView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowsTotal >= parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -205,12 +244,27 @@ export function ReviewWorkflowsCreateView() { } }, [ isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowsTotal, currentWorkflow.stages.length, ]); + React.useEffect(() => { + const filteredRoles = roles.filter((role) => role.code !== 'strapi-super-admin'); + + if (!isLoading && filteredRoles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + return ( <> @@ -225,7 +279,7 @@ export function ReviewWorkflowsCreateView() { type="submit" size="M" disabled={!currentWorkflowIsDirty} - isLoading={isLoading} + isLoading={isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -247,7 +301,7 @@ export function ReviewWorkflowsCreateView() { /> - {isLoadingModels ? ( + {isLoading ? ( {formatMessage({ id: 'Settings.review-workflows.page.isLoading', @@ -256,10 +310,7 @@ export function ReviewWorkflowsCreateView() { ) : ( - + )} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js index e86286af07..6a4bc2cd87 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js @@ -16,11 +16,19 @@ import { useMutation } from 'react-query'; import { useSelector, useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors'; import { useLicenseLimits } from '../../../../../../hooks'; -import { resetWorkflow, setWorkflow } from '../../actions'; +import { + resetWorkflow, + setIsLoading, + setWorkflow, + setContentTypes, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -31,7 +39,15 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectHasDeletedServerStages, + selectIsLoading, + selectRoles, + selectServerState, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsEditView() { @@ -42,29 +58,22 @@ export function ReviewWorkflowsEditView() { const { put } = useFetchClient(); const { formatAPIError } = useAPIErrorHandler(); const toggleNotification = useNotification(); - const { - isLoading: isWorkflowLoading, - meta, - workflows, - status: workflowStatus, - refetch, - } = useReviewWorkflows(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { - status, - clientState: { - currentWorkflow: { - data: currentWorkflow, - isDirty: currentWorkflowIsDirty, - hasDeletedServerStages, - }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { isLoading: isLoadingWorkflow, meta, workflows, refetch } = useReviewWorkflows(); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const serverState = useSelector(selectServerState); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const hasDeletedServerStages = useSelector(selectHasDeletedServerStages); + const roles = useSelector(selectRoles); + const isLoading = useSelector(selectIsLoading); const { allowedActions: { canDelete, canUpdate }, } = useRBAC(permissions.settings['review-workflows']); const [savePrompts, setSavePrompts] = React.useState({}); const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); const [showLimitModal, setShowLimitModal] = React.useState(false); const [initialErrors, setInitialErrors] = React.useState(null); @@ -73,7 +82,7 @@ export function ReviewWorkflowsEditView() { .filter((workflow) => workflow.id !== parseInt(workflowId, 10)) .flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -98,7 +107,32 @@ export function ReviewWorkflowsEditView() { setInitialErrors(null); try { - const res = await mutateAsync({ workflow }); + const res = await mutateAsync({ + workflow: { + ...workflow, + + // compare permissions of stages and only submit them if at least one has + // changed; this enables partial updates e.g. for users who don't have + // permissions to see roles + stages: workflow.stages.map((stage) => { + const hasUpdatedPermissions = + stage?.permissions?.length > 0 + ? stage.permissions.some( + ({ role }) => + !serverState.workflow.stages.find( + (stage) => + !!(stage.permissions ?? []).find((permission) => permission.role === role) + ) + ) + : false; + + return { + ...stage, + permissions: hasUpdatedPermissions ? stage.permissions : undefined, + }; + }), + }, + }); return res; } catch (error) { @@ -194,14 +228,37 @@ export function ReviewWorkflowsEditView() { const limits = getFeature('review-workflows'); React.useEffect(() => { - dispatch(setWorkflow({ status: workflowStatus, data: workflow })); + if (!isLoadingWorkflow) { + dispatch(setWorkflow({ workflow })); + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingWorkflow || isLoadingContentTypes || isLoadingRoles)); // reset the state to the initial state to avoid flashes if a user // navigates from an edit-view to a create-view return () => { dispatch(resetWorkflow()); }; - }, [workflowStatus, workflow, dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingWorkflow, + isLoadingRoles, + serverRoles, + singleTypes, + workflow, + workflows, + ]); /** * If the current license has a limit: @@ -217,7 +274,7 @@ export function ReviewWorkflowsEditView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowCount > parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -234,12 +291,27 @@ export function ReviewWorkflowsEditView() { }, [ currentWorkflow.stages.length, isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowCount, meta.workflowsTotal, ]); + React.useEffect(() => { + const filteredRoles = roles.filter((role) => role.code !== 'strapi-super-admin'); + + if (!isLoading && filteredRoles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + // TODO: redirect back to list-view if workflow is not found? return ( @@ -259,7 +331,7 @@ export function ReviewWorkflowsEditView() { disabled={!currentWorkflowIsDirty} // if the confirm dialog is open the loading state is on // the confirm button already - loading={!Object.keys(savePrompts).length > 0 && isLoading} + loading={!Object.keys(savePrompts).length > 0 && isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -269,7 +341,7 @@ export function ReviewWorkflowsEditView() { ) } subtitle={ - currentWorkflow.stages.length > 0 && + !isLoadingWorkflow && formatMessage( { id: 'Settings.review-workflows.page.subtitle', @@ -282,7 +354,7 @@ export function ReviewWorkflowsEditView() { /> - {isLoadingModels || status === 'loading' ? ( + {isLoading ? ( {formatMessage({ @@ -293,12 +365,7 @@ export function ReviewWorkflowsEditView() { ) : ( - + ({ ...stage, // A safety net in case a stage does not have a color assigned; - // this normallly should not happen + // this should not happen color: stage?.color ?? STAGE_COLOR_DEFAULT, })), }; } + break; + } - draft.clientState.currentWorkflow.hasDeletedServerStages = false; + case ACTION_SET_WORKFLOWS: { + draft.serverState.workflows = payload; break; } case ACTION_RESET_WORKFLOW: { - draft.clientState.currentWorkflow.data = initialState.clientState.currentWorkflow.data; + draft.clientState = initialState.clientState; draft.serverState = initialState.serverState; break; } @@ -71,12 +95,6 @@ export function reducer(state = initialState, action) { (stage) => (stage?.id ?? stage.__temp_key__) !== stageId ); - if (!currentWorkflow.hasDeletedServerStages) { - draft.clientState.currentWorkflow.hasDeletedServerStages = !!( - state.serverState.workflow?.stages ?? [] - ).find((stage) => stage.id === stageId); - } - break; } @@ -149,16 +167,6 @@ export function reducer(state = initialState, action) { default: break; } - - if (state.clientState.currentWorkflow.data && draft.serverState.workflow) { - draft.clientState.currentWorkflow.isDirty = !isEqual( - current(draft.clientState.currentWorkflow).data, - draft.serverState.workflow - ); - } else { - // if there is no workflow on the server, the workflow is awalys considered dirty - draft.clientState.currentWorkflow.isDirty = true; - } }); } diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js index 1c4d4d84f5..09f059d08f 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js @@ -3,6 +3,9 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, @@ -33,10 +36,57 @@ describe('Admin | Settings | Review Workflows | reducer', () => { state = initialState; }); + test('ACTION_SET_IS_LOADING', () => { + const action = { + type: ACTION_SET_IS_LOADING, + payload: true, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + isLoading: true, + }), + }) + ); + }); + + test('ACTION_SET_CONTENT_TYPES', () => { + const action = { + type: ACTION_SET_CONTENT_TYPES, + payload: { collectionTypes: [{ id: 1 }] }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + contentTypes: { + collectionTypes: [{ id: 1 }], + }, + }), + }) + ); + }); + + test('ACTION_SET_ROLES', () => { + const action = { + type: ACTION_SET_ROLES, + payload: [{ id: 1 }], + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + roles: [{ id: 1 }], + }), + }) + ); + }); + test('ACTION_SET_WORKFLOW with workflows', () => { const action = { type: ACTION_SET_WORKFLOW, - payload: { status: 'loading-state', workflow: WORKFLOW_FIXTURE }, + payload: WORKFLOW_FIXTURE, }; const DEFAULT_WORKFLOW_FIXTURE = { @@ -51,15 +101,12 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect(reducer(state, action)).toStrictEqual( expect.objectContaining({ - status: 'loading-state', serverState: expect.objectContaining({ workflow: WORKFLOW_FIXTURE, }), clientState: expect.objectContaining({ currentWorkflow: expect.objectContaining({ data: DEFAULT_WORKFLOW_FIXTURE, - isDirty: false, - hasDeletedServerStages: false, }), }), }) @@ -78,7 +125,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { workflow: WORKFLOW_FIXTURE, }, clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -95,107 +142,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to true if stageId exists on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to false if stageId does not exist on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: { - ...WORKFLOW_FIXTURE, - stages: [...WORKFLOW_FIXTURE.stages, { __temp_key__: 3, name: 'something' }], - }, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: false, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - keep hasDeletedServerStages true as soon as one server stage has been deleted', () => { - const actionDeleteServerStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - const actionDeleteClientStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - state = reducer(state, actionDeleteServerStage); - state = reducer(state, actionDeleteClientStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - test('ACTION_ADD_STAGE', () => { const action = { type: ACTION_ADD_STAGE, @@ -206,7 +152,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -239,7 +185,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: null, isDirty: false }, + currentWorkflow: { data: null }, }, }; @@ -287,7 +233,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -320,7 +266,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -343,52 +289,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('properly compare serverState and clientState and set isDirty accordingly', () => { - const actionAddStage = { - type: ACTION_ADD_STAGE, - payload: { name: 'something' }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, - }, - }; - - state = reducer(state, actionAddStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: true, - }), - }), - }) - ); - - const actionDeleteStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = reducer(state, actionDeleteStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: false, - }), - }), - }) - ); - }); - test('ACTION_UPDATE_STAGE_POSITION', () => { const action = { type: ACTION_UPDATE_STAGE_POSITION, @@ -403,7 +303,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -418,7 +317,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-1' }), ], }), - isDirty: true, }), }), }) @@ -439,7 +337,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -454,7 +351,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -475,7 +371,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -490,7 +385,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -511,7 +405,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -523,7 +416,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { data: expect.objectContaining({ name: 'test', }), - isDirty: true, }), }), }) @@ -543,7 +435,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -556,7 +447,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { name: '', stages: [], }), - isDirty: true, }), }), }) diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js new file mode 100644 index 0000000000..a65581e83a --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js @@ -0,0 +1,45 @@ +import isEqual from 'lodash/isEqual'; +import { createSelector } from 'reselect'; + +import { REDUX_NAMESPACE } from './constants'; +import { initialState } from './reducer'; + +export const selectNamespace = (state) => state[REDUX_NAMESPACE] ?? initialState; + +export const selectContentTypes = createSelector( + selectNamespace, + ({ serverState: { contentTypes } }) => contentTypes +); + +export const selectRoles = createSelector(selectNamespace, ({ serverState: { roles } }) => roles); + +export const selectCurrentWorkflow = createSelector( + selectNamespace, + ({ clientState: { currentWorkflow } }) => currentWorkflow.data +); + +export const selectWorkflows = createSelector( + selectNamespace, + ({ serverState: { workflows } }) => workflows +); + +export const selectIsWorkflowDirty = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !isEqual(serverState.workflow, currentWorkflow.data) +); + +export const selectHasDeletedServerStages = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !(serverState.workflow?.stages ?? []).every( + (stage) => !!currentWorkflow.data.stages.find(({ id }) => id === stage.id) + ) +); + +export const selectIsLoading = createSelector( + selectNamespace, + ({ clientState: { isLoading } }) => isLoading +); + +export const selectServerState = createSelector(selectNamespace, ({ serverState }) => serverState); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js index 9fe921b9da..f7c65a84d1 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js @@ -145,4 +145,79 @@ describe('Settings | Review Workflows | validateWorkflow()', () => { } `); }); + + test('stages.permissions: array', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [{ role: 1, action: 'admin::review-workflow.stage.transition' }], + }, + ], + }) + ).toEqual(true); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [], + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "Must be either an array or undefined", + }, + ], + } + `); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: { role: '1', action: 'admin::review-workflow.stage.transition' }, + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "stages[0].permissions must be a \`array\` type, but the final value was: \`{ + "role": "\\"1\\"", + "action": "\\"admin::review-workflow.stage.transition\\"" + }\`.", + }, + ], + } + `); + }); + + test('stages.permissions: undefined', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: undefined, + }, + ], + }) + ).toEqual(true); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js index cdac8102ce..1c1fc22225 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js @@ -57,6 +57,33 @@ export async function validateWorkflow({ values, formatMessage }) { }) ) .matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), + + permissions: yup + .array( + yup.object({ + role: yup + .number() + .strict() + .typeError( + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions.role.number', + defaultMessage: 'Role must be of type number', + }) + ).required, + action: yup.string().required({ + id: 'Settings.review-workflows.validation.stage.permissions.action.required', + defaultMessage: 'Action is a required argument', + }), + }) + ) + .strict() + .min( + 1, + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions', + defaultMessage: 'Must be either an array or undefined', + }) + ), }) ) .min(1), From 2550ded2254c3cd90bb4f3ed64d55c451da618a4 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 8 Aug 2023 12:43:54 +0200 Subject: [PATCH 25/32] Fix: Make return values of data-fetching hooks stable --- .../admin/src/hooks/useAdminRoles/index.js | 24 ++++++++++++------ .../src/hooks/useAdminUsers/useAdminUsers.js | 22 ++++++++++------ .../hooks/useContentTypes/useContentTypes.js | 25 +++++++++++++------ .../hooks/useReviewWorkflows.js | 23 +++++++++++------ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index a0ea4520aa..c934afbbc9 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useCollator, useFetchClient } from '@strapi/helper-plugin'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; @@ -22,16 +24,24 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { queryOptions ); - let roles = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const roles = React.useMemo(() => { + let roles = []; - if (id && data) { - roles = [data.data]; - } else if (Array.isArray(data?.data)) { - roles = data.data; - } + if (id && data) { + roles = [data.data]; + } else if (Array.isArray(data?.data)) { + roles = data.data; + } + + return [...roles].sort((a, b) => formatter.compare(a.name, b.name)); + }, [data, id, formatter]); return { - roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), + roles, error, isError, isLoading, diff --git a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js index a4ea385bb1..f477e1aa5e 100644 --- a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js +++ b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,17 +22,23 @@ export function useAdminUsers(params = {}, queryOptions = {}) { queryOptions ); - let users = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const users = React.useMemo(() => { + if (id && data) { + return [data]; + } else if (Array.isArray(data?.results)) { + return data.results; + } - if (id && data) { - users = [data]; - } else if (Array.isArray(data?.results)) { - users = data.results; - } + return []; + }, [data, id]); return { users, - pagination: data?.pagination ?? null, + pagination: React.useMemo(() => data?.pagination ?? null, [data?.pagination]), isLoading, isError, refetch, diff --git a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js index 52ba5fe4fb..1917f9bc69 100644 --- a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js +++ b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useAPIErrorHandler, useFetchClient, useNotification } from '@strapi/helper-plugin'; import { useQueries } from 'react-query'; @@ -29,16 +31,25 @@ export function useContentTypes() { const [components, contentTypes] = queries; const isLoading = components.isLoading || contentTypes.isLoading; - const collectionTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed - ); - const singleTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed - ); + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const collectionTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); + + const singleTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); return { isLoading, - components: components?.data ?? [], + components: React.useMemo(() => components?.data ?? [], [components?.data]), collectionTypes, singleTypes, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js index 2cc79fedb5..696dff44bf 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,18 +22,25 @@ export function useReviewWorkflows(params = {}) { } ); - let workflows = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks - if (id && data?.data) { - workflows = [data.data]; - } else if (Array.isArray(data?.data)) { - workflows = data.data; - } + const workflows = React.useMemo(() => { + if (id && data?.data) { + return [data.data]; + } else if (Array.isArray(data?.data)) { + return data.data; + } + + return []; + }, [data?.data, id]); return { // meta contains e.g. the total of all workflows. we can not use // the pagination object here, because the list is not paginated. - meta: data?.meta ?? {}, + meta: React.useMemo(() => data?.meta ?? {}, [data?.meta]), workflows, isLoading, status, From 8947aae67b7bf944d3bc7a5f3b8c7b4205aa07d6 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 1 Aug 2023 14:28:44 +0200 Subject: [PATCH 26/32] Enhancement: Render permissions for each workflow stage --- .../admin/src/hooks/useAdminRoles/index.js | 2 +- .../useLicenseLimits/useLicenseLimits.js | 12 +- .../pages/ReviewWorkflows/actions/index.js | 39 ++- .../actions/tests/index.test.js | 79 ++++++- .../components/Stages/Stage/Stage.js | 111 ++++++++- .../Stages/Stage/tests/Stage.test.js | 214 ++++++++++++++--- .../components/Stages/tests/Stages.test.js | 88 +++---- .../WorkflowAttributes/WorkflowAttributes.js | 31 +-- .../tests/WorkflowAttributes.test.js | 198 ++++++++-------- .../pages/ReviewWorkflows/constants.js | 4 + .../pages/CreateView/CreateView.js | 87 +++++-- .../pages/EditView/EditView.js | 135 ++++++++--- .../pages/ReviewWorkflows/reducer/index.js | 62 ++--- .../reducer/tests/index.test.js | 222 +++++------------- .../pages/ReviewWorkflows/selectors.js | 45 ++++ .../utils/tests/validateWorkflow.test.js | 75 ++++++ .../ReviewWorkflows/utils/validateWorkflow.js | 27 +++ 17 files changed, 958 insertions(+), 473 deletions(-) create mode 100644 packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index 86ffcda4dc..a0ea4520aa 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -31,7 +31,7 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { } return { - roles: roles.sort((a, b) => formatter.compare(a.name, b.name)), + roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), error, isError, isLoading, diff --git a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js index 774d49c9dc..60e690c5e3 100644 --- a/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js +++ b/packages/core/admin/ee/admin/hooks/useLicenseLimits/useLicenseLimits.js @@ -3,7 +3,7 @@ import * as React from 'react'; import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; -export function useLicenseLimits({ enabled } = { enabled: true }) { +export function useLicenseLimits(queryOptions = {}) { const { get } = useFetchClient(); const { data, isError, isLoading } = useQuery( ['ee', 'license-limit-info'], @@ -15,11 +15,17 @@ export function useLicenseLimits({ enabled } = { enabled: true }) { return data; }, { - enabled, + ...queryOptions, + + // the request is expected to fail sometimes if a user does not + // have permissions + retry: false, } ); - const license = data ?? {}; + const license = React.useMemo(() => { + return data ?? {}; + }, [data]); const getFeature = React.useCallback( (name) => { diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js index 9ebb777b13..5711ee9924 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js @@ -2,19 +2,27 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, + ACTION_SET_WORKFLOWS, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, ACTION_UPDATE_WORKFLOW, } from '../constants'; -export function setWorkflow({ status, data }) { +export function setWorkflow({ workflow }) { return { type: ACTION_SET_WORKFLOW, - payload: { - status, - workflow: data, - }, + payload: workflow, + }; +} + +export function setWorkflows({ workflows }) { + return { + type: ACTION_SET_WORKFLOWS, + payload: workflows, }; } @@ -66,3 +74,24 @@ export function resetWorkflow() { type: ACTION_RESET_WORKFLOW, }; } + +export function setContentTypes(payload) { + return { + type: ACTION_SET_CONTENT_TYPES, + payload, + }; +} + +export function setRoles(payload) { + return { + type: ACTION_SET_ROLES, + payload, + }; +} + +export function setIsLoading(isLoading) { + return { + type: ACTION_SET_IS_LOADING, + payload: isLoading, + }; +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js index 0cb1ac3177..45e0e2699e 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js @@ -1,19 +1,42 @@ -import { addStage, deleteStage, setWorkflow, updateStage } from '..'; +import { + addStage, + deleteStage, + setWorkflow, + setWorkflows, + updateStage, + updateStagePosition, + updateWorkflow, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, +} from '..'; import { ACTION_SET_WORKFLOW, ACTION_DELETE_STAGE, ACTION_ADD_STAGE, ACTION_UPDATE_STAGE, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, + ACTION_SET_WORKFLOWS, + ACTION_UPDATE_STAGE_POSITION, + ACTION_RESET_WORKFLOW, + ACTION_UPDATE_WORKFLOW, } from '../../constants'; describe('Admin | Settings | Review Workflow | actions', () => { test('setWorkflow()', () => { - expect(setWorkflow({ status: 'loading', data: null, something: 'else' })).toStrictEqual({ + expect(setWorkflow({ workflow: null, something: 'else' })).toStrictEqual({ type: ACTION_SET_WORKFLOW, - payload: { - status: 'loading', - workflow: null, - }, + payload: null, + }); + }); + + test('setWorkflows()', () => { + expect(setWorkflows({ workflows: [] })).toStrictEqual({ + type: ACTION_SET_WORKFLOWS, + payload: [], }); }); @@ -49,4 +72,48 @@ describe('Admin | Settings | Review Workflow | actions', () => { }, }); }); + + test('updateStagePosition()', () => { + expect(updateStagePosition(1, 2)).toStrictEqual({ + type: ACTION_UPDATE_STAGE_POSITION, + payload: { + newIndex: 2, + oldIndex: 1, + }, + }); + }); + + test('updateWorkflow()', () => { + expect(updateWorkflow({})).toStrictEqual({ + type: ACTION_UPDATE_WORKFLOW, + payload: {}, + }); + }); + + test('resetWorkflow()', () => { + expect(resetWorkflow()).toStrictEqual({ + type: ACTION_RESET_WORKFLOW, + }); + }); + + test('setContentTypes()', () => { + expect(setContentTypes({})).toStrictEqual({ + type: ACTION_SET_CONTENT_TYPES, + payload: {}, + }); + }); + + test('setRoles()', () => { + expect(setRoles({})).toStrictEqual({ + type: ACTION_SET_ROLES, + payload: {}, + }); + }); + + test('setIsLoading()', () => { + expect(setIsLoading(true)).toStrictEqual({ + type: ACTION_SET_IS_LOADING, + payload: true, + }); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js index efd000bc14..21f6ca8a83 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js @@ -9,25 +9,34 @@ import { Grid, GridItem, IconButton, + MultiSelect, + MultiSelectGroup, + MultiSelectOption, SingleSelect, SingleSelectOption, TextInput, VisuallyHidden, } from '@strapi/design-system'; -import { useTracking } from '@strapi/helper-plugin'; +import { NotAllowedInput, useTracking } from '@strapi/helper-plugin'; import { Drag, Trash } from '@strapi/icons'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; import { useDragAndDrop } from '../../../../../../../../../admin/src/content-manager/hooks'; import { composeRefs } from '../../../../../../../../../admin/src/content-manager/utils'; import { deleteStage, updateStage, updateStagePosition } from '../../../actions'; import { DRAG_DROP_TYPES } from '../../../constants'; +import { selectRoles } from '../../../selectors'; import { getAvailableStageColors, getStageColorByHex } from '../../../utils/colors'; +const NestedOption = styled(MultiSelectOption)` + padding-left: ${({ theme }) => theme.spaces[7]}; +`; + const AVAILABLE_COLORS = getAvailableStageColors(); function StageDropPreview() { @@ -144,6 +153,10 @@ export function Stage({ const [isOpen, setIsOpen] = React.useState(isOpenDefault); const [nameField, nameMeta, nameHelper] = useField(`stages.${index}.name`); const [colorField, colorMeta, colorHelper] = useField(`stages.${index}.color`); + const [permissionsField, permissionsMeta, permissionsHelper] = useField( + `stages.${index}.permissions` + ); + const roles = useSelector(selectRoles); const [{ handlerId, isDragging, handleKeyDown }, stageRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(canReorder, { index, @@ -171,12 +184,17 @@ export function Stage({ color: hex, })); + const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; + + const filteredRoles = roles + // Super admins always have permissions to do everything and therefore + // there is no point for this role to show up in the role combobox + .filter((role) => role.code !== 'strapi-super-admin'); + React.useEffect(() => { dragPreviewRef(getEmptyImage(), { captureDraggingState: false }); }, [dragPreviewRef, index]); - const { themeColorName } = getStageColorByHex(colorField.value) ?? {}; - return ( {liveText && {liveText}} @@ -315,6 +333,91 @@ export function Stage({ })} + + + {filteredRoles.length === 0 ? ( + + ) : ( + { + // Because the select components expects strings for values, but + // the yup schema validates we are sending full permission objects to the API, + // we must coerce the string value back to an object + const permissions = values.map((value) => ({ + role: parseInt(value, 10), + action: 'admin::review-workflows.stage.transition', + })); + + permissionsHelper.setValue(permissions); + dispatch(updateStage(id, { permissions })); + }} + placeholder={formatMessage({ + id: 'Settings.review-workflows.stage.permissions.placeholder', + defaultMessage: 'Select a role', + })} + required + // The Select component expects strings for values + value={(permissionsField.value ?? []).map((permission) => `${permission.role}`)} + withTags + > + {[ + { + label: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.allRoles.label', + defaultMessage: 'All roles', + }), + + children: filteredRoles.map((role) => ({ + value: `${role.id}`, + label: role.name, + })), + }, + ].map((role) => { + if ('children' in role) { + return ( + child.value)} + > + {role.children.map((role) => { + return ( + + {role.label} + + ); + })} + + ); + } + + return ( + + {role.label} + + ); + })} + + )} + diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js index f31a7a8290..1ee2a8f7e6 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js @@ -1,16 +1,16 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../../admin/src/core/store/configureStore'; -import { STAGE_COLOR_DEFAULT } from '../../../../constants'; +import { REDUX_NAMESPACE, STAGE_COLOR_DEFAULT } from '../../../../constants'; import { reducer } from '../../../../reducer'; import { Stage } from '../Stage'; @@ -19,18 +19,86 @@ const STAGES_FIXTURE = { index: 0, }; +const WORKFLOWS_FIXTURE = [ + { + id: 1, + name: 'Default', + contentTypes: ['uid1'], + stages: [], + }, + + { + id: 2, + name: 'Default 2', + contentTypes: ['uid2'], + stages: [], + }, +]; + +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], +}; + +const ROLES_FIXTURE = [ + { + id: 1, + code: 'strapi-editor', + name: 'Editor', + }, + + { + id: 2, + code: 'strapi-author', + name: 'Author', + }, + + { + id: 3, + code: 'strapi-super-admin', + name: 'Super Admin', + }, +]; + const ComponentFixture = ({ // eslint-disable-next-line react/prop-types stages = [ { color: STAGE_COLOR_DEFAULT, name: 'something', + permissions: [{ role: 1, action: 'admin::review-workflows.stage.transition' }], }, ], ...props }) => { - const store = configureStore([], [reducer]); - const formik = useFormik({ enableReinitialize: true, initialValues: { @@ -40,21 +108,43 @@ const ComponentFixture = ({ }); return ( - - - - - - - - - - - + + + ); }; -const setup = (props) => render(); +const setup = ({ roles, ...props } = {}) => + render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: CONTENT_TYPES_FIXTURE, + roles: roles || ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }); const user = userEvent.setup(); @@ -72,11 +162,25 @@ describe('Admin | Settings | Review Workflow | Stage', () => { // does not have better identifiers await user.click(container.querySelector('button[aria-expanded]')); - expect(queryByRole('textbox')).toBeInTheDocument(); - expect(getByRole('textbox').value).toBe('something'); + // Expect the accordion header to have the same value as the textbox + expect(getByRole('button', { name: /something/i })); expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name'); - expect(getByRole('combobox')).toHaveTextContent('Blue'); + // Name + expect(queryByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox').value).toBe('something'); + + // Color combobox + await waitFor(() => + expect(getByRole('combobox', { name: /color/i })).toHaveTextContent('Blue') + ); + + // Permissions combobox + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toHaveTextContent('Editor') + ); expect( queryByRole('button', { @@ -88,27 +192,31 @@ describe('Admin | Settings | Review Workflow | Stage', () => { it('should open the accordion panel if isOpen = true', async () => { const { queryByRole } = setup({ isOpen: true }); - expect(queryByRole('textbox')).toBeInTheDocument(); + await waitFor(() => expect(queryByRole('textbox')).toBeInTheDocument()); }); it('should not render the delete button if canDelete=false', async () => { const { queryByRole } = setup({ isOpen: true, canDelete: false }); - expect( - queryByRole('button', { - name: /delete stage/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /delete stage/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not render delete drag button if canUpdate=false', async () => { const { queryByRole } = setup({ isOpen: true, canUpdate: false }); - expect( - queryByRole('button', { - name: /drag/i, - }) - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('button', { + name: /drag/i, + }) + ).not.toBeInTheDocument() + ); }); it('should not crash on a custom color code', async () => { @@ -131,7 +239,49 @@ describe('Admin | Settings | Review Workflow | Stage', () => { await user.click(container.querySelector('button[aria-expanded]')); + // Name expect(getByRole('textbox')).toHaveAttribute('disabled'); - expect(getByRole('combobox')).toHaveAttribute('data-disabled'); + + // Color + expect(getByRole('combobox', { name: /color/i })).toHaveAttribute('data-disabled'); + + // Permissions + expect(getByRole('combobox', { name: /roles that can change this stage/i })).toHaveAttribute( + 'data-disabled' + ); + }); + + it('should render a list of all available roles (except super admins)', async () => { + const { container, getByRole, queryByRole } = setup({ canUpdate: true }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect( + getByRole('combobox', { name: /roles that can change this stage/i }) + ).toBeInTheDocument() + ); + + await user.click(getByRole('combobox', { name: /roles that can change this stage/i })); + + await waitFor(() => expect(getByRole('option', { name: /All roles/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Editor/i })).toBeInTheDocument()); + await waitFor(() => expect(getByRole('option', { name: /Author/i })).toBeInTheDocument()); + await waitFor(() => + expect(queryByRole('option', { name: /Super Admin/i })).not.toBeInTheDocument() + ); + }); + + it('should render a no permissions fallback, if no roles are available', async () => { + const { container, getByText } = setup({ + canUpdate: true, + roles: [...ROLES_FIXTURE].filter((role) => role.code === 'strapi-super-admin'), + }); + + await user.click(container.querySelector('button[aria-expanded]')); + + await waitFor(() => + expect(getByText(/you don’t have the permission to see roles/i)).toBeInTheDocument() + ); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js index 07f6286491..9877245eed 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js @@ -1,9 +1,8 @@ import React from 'react'; import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { fireEvent, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { FormikProvider, useFormik } from 'formik'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; @@ -11,7 +10,7 @@ import { Provider } from 'react-redux'; import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; import * as actions from '../../../actions'; -import { ACTION_SET_WORKFLOW, STAGE_COLOR_DEFAULT } from '../../../constants'; +import { STAGE_COLOR_DEFAULT } from '../../../constants'; import { reducer } from '../../../reducer'; import { Stages } from '../Stages'; @@ -21,6 +20,15 @@ jest.mock('../../../actions', () => ({ ...jest.requireActual('../../../actions'), })); +// A single stage needs a formik provider, which is a bit complicated to setup. +// Since we don't want to test the single stages, but the overall composition +// it is the easiest for the test setup to just render an id instead of the +// whole component. +jest.mock('../Stage', () => ({ + __esModule: true, + Stage: ({ id }) => id, +})); + const STAGES_FIXTURE = [ { id: 1, @@ -35,44 +43,25 @@ const STAGES_FIXTURE = [ }, ]; -const WORKFLOWS_FIXTURE = [ - { - id: 1, - stages: STAGES_FIXTURE, - }, -]; +const setup = (props) => ({ + ...render(, { + wrapper({ children }) { + const store = configureStore([], [reducer]); -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); - - store.dispatch({ type: ACTION_SET_WORKFLOW, payload: { workflows: WORKFLOWS_FIXTURE } }); - - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - stages: STAGES_FIXTURE, + return ( + + + + {children} + + + + ); }, - validateOnChange: false, - }); + }), - return ( - - - - - - - - - - - - ); -}; - -const setup = (props) => render(); - -const user = userEvent.setup(); + user: userEvent.setup(), +}); describe('Admin | Settings | Review Workflow | Stages', () => { beforeEach(() => { @@ -82,8 +71,8 @@ describe('Admin | Settings | Review Workflow | Stages', () => { it('should render a list of stages', () => { const { getByText } = setup(); - expect(getByText(STAGES_FIXTURE[0].name)).toBeInTheDocument(); - expect(getByText(STAGES_FIXTURE[1].name)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[0].id)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[1].id)).toBeInTheDocument(); }); it('should render a "add new stage" button', () => { @@ -93,7 +82,7 @@ describe('Admin | Settings | Review Workflow | Stages', () => { }); it('should append a new stage when clicking "add new stage"', async () => { - const { getByRole } = setup(); + const { getByRole, user } = setup(); const spy = jest.spyOn(actions, 'addStage'); await user.click( @@ -106,23 +95,6 @@ describe('Admin | Settings | Review Workflow | Stages', () => { expect(spy).toBeCalledWith({ name: '' }); }); - it('should update the name of a stage by changing the input value', async () => { - const { queryByRole, getByRole } = setup(); - const spy = jest.spyOn(actions, 'updateStage'); - - await user.click(getByRole('button', { name: /stage-2/i })); - - const input = queryByRole('textbox', { - name: /stage name/i, - }); - - fireEvent.change(input, { target: { value: 'New name' } }); - - expect(spy).toBeCalledWith(2, { - name: 'New name', - }); - }); - it('should not render the "add stage" button if canUpdate = false', () => { const { queryByText } = setup({ canUpdate: false }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js index 46f6c45f4e..af8f0e72c3 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/WorkflowAttributes.js @@ -13,10 +13,11 @@ import { useCollator } from '@strapi/helper-plugin'; import { useField } from 'formik'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { updateWorkflow } from '../../actions'; +import { selectContentTypes, selectCurrentWorkflow, selectWorkflows } from '../../selectors'; const NestedOption = styled(MultiSelectOption)` padding-left: ${({ theme }) => theme.spaces[7]}; @@ -26,14 +27,12 @@ const ContentTypeTakeNotice = styled(Typography)` font-style: italic; `; -export function WorkflowAttributes({ - canUpdate, - contentTypes: { collectionTypes, singleTypes }, - currentWorkflow, - workflows, -}) { +export function WorkflowAttributes({ canUpdate }) { const { formatMessage, locale } = useIntl(); const dispatch = useDispatch(); + const { collectionTypes, singleTypes } = useSelector(selectContentTypes); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const workflows = useSelector(selectWorkflows); const [nameField, nameMeta, nameHelper] = useField('name'); const [contentTypesField, contentTypesMeta, contentTypesHelper] = useField('contentTypes'); const formatter = useCollator(locale, { @@ -97,7 +96,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.collectionTypes.label', defaultMessage: 'Collection Types', }), - children: collectionTypes + children: [...collectionTypes] .sort((a, b) => formatter.compare(a.info.displayName, b.info.displayName)) .map((contentType) => ({ label: contentType.info.displayName, @@ -114,7 +113,7 @@ export function WorkflowAttributes({ id: 'Settings.review-workflows.workflow.contentTypes.singleTypes.label', defaultMessage: 'Single Types', }), - children: singleTypes.map((contentType) => ({ + children: [...singleTypes].map((contentType) => ({ label: contentType.info.displayName, value: contentType.uid, })), @@ -178,24 +177,10 @@ export function WorkflowAttributes({ ); } -const ContentTypeType = PropTypes.shape({ - uid: PropTypes.string.isRequired, - info: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - }).isRequired, -}); - WorkflowAttributes.defaultProps = { canUpdate: true, - currentWorkflow: undefined, }; WorkflowAttributes.propTypes = { canUpdate: PropTypes.bool, - contentTypes: PropTypes.shape({ - collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired, - }).isRequired, - currentWorkflow: PropTypes.object, - workflows: PropTypes.array.isRequired, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js index 732e18a430..04edf9ac0b 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/WorkflowAttributes/tests/WorkflowAttributes.test.js @@ -8,31 +8,12 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createStore } from 'redux'; -import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; +import { REDUX_NAMESPACE } from '../../../constants'; import { reducer } from '../../../reducer'; import { WorkflowAttributes } from '../WorkflowAttributes'; -const CONTENT_TYPES_FIXTURE = { - collectionTypes: [ - { - uid: 'uid1', - info: { - displayName: 'Content Type 1', - }, - }, - ], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], -}; - const WORKFLOWS_FIXTURE = [ { id: 1, @@ -43,48 +24,63 @@ const WORKFLOWS_FIXTURE = [ { id: 2, - name: 'Workflow 1', - contentTypes: [], + name: 'Default 2', + contentTypes: ['uid2'], stages: [], }, ]; -const CURRENT_WORKFLOW_FIXTURE = { - ...WORKFLOWS_FIXTURE[0], +const CONTENT_TYPES_FIXTURE = { + collectionTypes: [ + { + uid: 'uid1', + info: { + displayName: 'Collection CT 1', + }, + }, + + { + uid: 'uid2', + info: { + displayName: 'Collection CT 2', + }, + }, + ], + singleTypes: [ + { + uid: 'single-uid1', + info: { + displayName: 'Single CT 1', + }, + }, + + { + uid: 'single-uid2', + info: { + displayName: 'Single CT 2', + }, + }, + ], }; -const ComponentFixture = (props) => { - const store = configureStore([], [reducer]); +const ROLES_FIXTURE = []; +// eslint-disable-next-line react/prop-types +const ComponentFixture = ({ currentWorkflow, ...props } = {}) => { const formik = useFormik({ enableReinitialize: true, - initialValues: { - name: 'workflow name', - contentTypes: ['uid1', 'uid1'], - }, + initialValues: currentWorkflow || WORKFLOWS_FIXTURE[0], validateOnChange: false, }); return ( - - - - - - - - - - - + + + ); }; +// eslint-disable-next-line no-unused-vars const withMarkup = (query) => (text) => query((content, node) => { const hasText = (node) => node.textContent === text; @@ -93,8 +89,40 @@ const withMarkup = (query) => (text) => return hasText(node) && childrenDontHaveText; }); -const setup = (props) => ({ - ...render(), +const setup = ({ collectionTypes, singleTypes, currentWorkflow, ...props } = {}) => ({ + ...render(, { + wrapper({ children }) { + const store = createStore(reducer, { + [REDUX_NAMESPACE]: { + serverState: { + contentTypes: { + collectionTypes: collectionTypes || CONTENT_TYPES_FIXTURE.collectionTypes, + singleTypes: singleTypes || CONTENT_TYPES_FIXTURE.singleTypes, + }, + roles: ROLES_FIXTURE, + workflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }, + + clientState: { + currentWorkflow: { + data: currentWorkflow || WORKFLOWS_FIXTURE[0], + }, + }, + }, + }); + + return ( + + + + {children} + + + + ); + }, + }), user: userEvent.setup(), }); @@ -102,20 +130,18 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { it('should render values', async () => { const { getByRole, getByText, user } = setup(); - const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); - - expect(getByRole('textbox')).toHaveValue('workflow name'); - expect(getByText(/2 content types selected/i)).toBeInTheDocument(); + await waitFor(() => expect(getByText(/workflow name/i)).toBeInTheDocument()); + expect(getByRole('textbox', { name: /workflow name \*/i })).toHaveValue('Default'); + expect(getByText(/1 content type selected/i)).toBeInTheDocument(); expect(getByRole('textbox')).not.toHaveAttribute('disabled'); expect(getByRole('combobox', { name: /associated to/i })).not.toHaveAttribute('data-disabled'); - await user.click(contentTypesSelect); + await user.click(getByRole('combobox', { name: /associated to/i })); - await waitFor(() => { - expect(getByRole('option', { name: /content type 1/i })).toBeInTheDocument(); - expect(getByRole('option', { name: /content type 2/i })).toBeInTheDocument(); - }); + await waitFor(() => + expect(getByRole('option', { name: /Collection CT 1/i })).toBeInTheDocument() + ); }); it('should disabled fields if canUpdate = false', async () => { @@ -127,20 +153,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not collection-types', async () => { + it('should not render a collection-type group if there are no collection-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [], - - singleTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - }, + collectionTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -153,20 +168,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render a collection-type group if there are not single-types', async () => { + it('should not render a collection-type group if there are no single-types', async () => { const { getByRole, queryByRole, user } = setup({ - contentTypes: { - collectionTypes: [ - { - uid: 'uid2', - info: { - displayName: 'Content Type 2', - }, - }, - ], - - singleTypes: [], - }, + singleTypes: [], }); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); @@ -188,23 +192,29 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { }); }); - it('should not render assigned content-types to the current workflow', async () => { + it('should not render the assigned content-types notice to the current workflow', async () => { const { getByRole, queryByText, user } = setup(); + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); + const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const queryByTextWithMarkup = withMarkup(queryByText); await user.click(contentTypesSelect); - await waitFor(() => { - expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument(); - }); + await waitFor(() => + expect(queryByTextWithMarkup('(assigned to Default workflow)')).not.toBeInTheDocument() + ); }); it('should render assigned content-types to the other workflows', async () => { - const { getByRole, getByText, user } = setup({ - currentWorkflow: { ...WORKFLOWS_FIXTURE[1] }, - }); + const { getByRole, getByText, user } = setup(); + + await waitFor(() => + expect(getByRole('combobox', { name: /associated to/i })).toBeInTheDocument() + ); const contentTypesSelect = getByRole('combobox', { name: /associated to/i }); const getByTextWithMarkup = withMarkup(getByText); @@ -212,11 +222,11 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); - it('should render assigned content-types to the other workflows, when currentWorkflow is not passed', async () => { + it('should render assigned content-types of other workflows, when currentWorkflow is not passed', async () => { const { getByRole, getByText, user } = setup({ currentWorkflow: undefined, }); @@ -227,7 +237,7 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => { await user.click(contentTypesSelect); await waitFor(() => { - expect(getByTextWithMarkup('(assigned to Default workflow)')).toBeInTheDocument(); + expect(getByTextWithMarkup('(assigned to Default 2 workflow)')).toBeInTheDocument(); }); }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js index aa63aa210b..742c843afa 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js @@ -3,7 +3,11 @@ import { lightTheme } from '@strapi/design-system'; export const REDUX_NAMESPACE = 'settings_review-workflows'; export const ACTION_RESET_WORKFLOW = `Settings/Review_Workflows/RESET_WORKFLOW`; +export const ACTION_SET_CONTENT_TYPES = `Settings/Review_Workflows/SET_CONTENT_TYPES`; +export const ACTION_SET_IS_LOADING = `Settings/Review_Workflows/SET_IS_LOADING`; +export const ACTION_SET_ROLES = `Settings/Review_Workflows/SET_ROLES`; export const ACTION_SET_WORKFLOW = `Settings/Review_Workflows/SET_WORKFLOW`; +export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`; export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`; export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`; export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js index 466fe26091..ac68dae07a 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/CreateView/CreateView.js @@ -15,10 +15,18 @@ import { useMutation } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { useLicenseLimits } from '../../../../../../hooks'; -import { addStage, resetWorkflow } from '../../actions'; +import { + addStage, + resetWorkflow, + setContentTypes, + setIsLoading, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -29,7 +37,13 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsLoading, + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectRoles, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsCreateView() { @@ -39,13 +53,15 @@ export function ReviewWorkflowsCreateView() { const { formatAPIError } = useAPIErrorHandler(); const dispatch = useDispatch(); const toggleNotification = useNotification(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { isLoading: isWorkflowLoading, meta, workflows } = useReviewWorkflows(); - const { - clientState: { - currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const { isLoading: isLoadingWorkflow, meta, workflows } = useReviewWorkflows(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); + const isLoading = useSelector(selectIsLoading); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const roles = useSelector(selectRoles); const [showLimitModal, setShowLimitModal] = React.useState(false); const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits(); const [initialErrors, setInitialErrors] = React.useState(null); @@ -54,7 +70,7 @@ export function ReviewWorkflowsCreateView() { const limits = getFeature('review-workflows'); const contentTypesFromOtherWorkflows = workflows.flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -167,13 +183,36 @@ export function ReviewWorkflowsCreateView() { React.useEffect(() => { dispatch(resetWorkflow()); + if (!isLoadingWorkflow) { + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingContentTypes || isLoadingRoles)); + // Create an empty default stage dispatch( addStage({ name: '', }) ); - }, [dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingRoles, + isLoadingWorkflow, + serverRoles, + singleTypes, + workflows, + ]); /** * If the current license has a limit: @@ -189,7 +228,7 @@ export function ReviewWorkflowsCreateView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowsTotal >= parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -205,12 +244,25 @@ export function ReviewWorkflowsCreateView() { } }, [ isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowsTotal, currentWorkflow.stages.length, ]); + React.useEffect(() => { + if (!isLoading && roles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + return ( <> @@ -225,7 +277,7 @@ export function ReviewWorkflowsCreateView() { type="submit" size="M" disabled={!currentWorkflowIsDirty} - isLoading={isLoading} + isLoading={isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -247,7 +299,7 @@ export function ReviewWorkflowsCreateView() { /> - {isLoadingModels ? ( + {isLoading ? ( {formatMessage({ id: 'Settings.review-workflows.page.isLoading', @@ -256,10 +308,7 @@ export function ReviewWorkflowsCreateView() { ) : ( - + )} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js index e86286af07..d83f47bea9 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/pages/EditView/EditView.js @@ -16,11 +16,19 @@ import { useMutation } from 'react-query'; import { useSelector, useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { useAdminRoles } from '../../../../../../../../admin/src/hooks/useAdminRoles'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors'; import { useLicenseLimits } from '../../../../../../hooks'; -import { resetWorkflow, setWorkflow } from '../../actions'; +import { + resetWorkflow, + setIsLoading, + setWorkflow, + setContentTypes, + setRoles, + setWorkflows, +} from '../../actions'; import * as Layout from '../../components/Layout'; import * as LimitsModal from '../../components/LimitsModal'; import { Stages } from '../../components/Stages'; @@ -31,7 +39,15 @@ import { REDUX_NAMESPACE, } from '../../constants'; import { useReviewWorkflows } from '../../hooks/useReviewWorkflows'; -import { reducer, initialState } from '../../reducer'; +import { reducer } from '../../reducer'; +import { + selectIsWorkflowDirty, + selectCurrentWorkflow, + selectHasDeletedServerStages, + selectIsLoading, + selectRoles, + selectServerState, +} from '../../selectors'; import { validateWorkflow } from '../../utils/validateWorkflow'; export function ReviewWorkflowsEditView() { @@ -42,29 +58,22 @@ export function ReviewWorkflowsEditView() { const { put } = useFetchClient(); const { formatAPIError } = useAPIErrorHandler(); const toggleNotification = useNotification(); - const { - isLoading: isWorkflowLoading, - meta, - workflows, - status: workflowStatus, - refetch, - } = useReviewWorkflows(); - const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); - const { - status, - clientState: { - currentWorkflow: { - data: currentWorkflow, - isDirty: currentWorkflowIsDirty, - hasDeletedServerStages, - }, - }, - } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); + const { isLoading: isLoadingWorkflow, meta, workflows, refetch } = useReviewWorkflows(); + const { collectionTypes, singleTypes, isLoading: isLoadingContentTypes } = useContentTypes(); + const serverState = useSelector(selectServerState); + const currentWorkflowIsDirty = useSelector(selectIsWorkflowDirty); + const currentWorkflow = useSelector(selectCurrentWorkflow); + const hasDeletedServerStages = useSelector(selectHasDeletedServerStages); + const roles = useSelector(selectRoles); + const isLoading = useSelector(selectIsLoading); const { allowedActions: { canDelete, canUpdate }, } = useRBAC(permissions.settings['review-workflows']); const [savePrompts, setSavePrompts] = React.useState({}); const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits(); + const { isLoading: isLoadingRoles, roles: serverRoles } = useAdminRoles(undefined, { + retry: false, + }); const [showLimitModal, setShowLimitModal] = React.useState(false); const [initialErrors, setInitialErrors] = React.useState(null); @@ -73,7 +82,7 @@ export function ReviewWorkflowsEditView() { .filter((workflow) => workflow.id !== parseInt(workflowId, 10)) .flatMap((workflow) => workflow.contentTypes); - const { mutateAsync, isLoading } = useMutation( + const { mutateAsync, isLoading: isLoadingMutation } = useMutation( async ({ workflow }) => { const { data: { data }, @@ -98,7 +107,32 @@ export function ReviewWorkflowsEditView() { setInitialErrors(null); try { - const res = await mutateAsync({ workflow }); + const res = await mutateAsync({ + workflow: { + ...workflow, + + // compare permissions of stages and only submit them if at least one has + // changed; this enables partial updates e.g. for users who don't have + // permissions to see roles + stages: workflow.stages.map((stage) => { + const hasUpdatedPermissions = + stage?.permissions?.length > 0 + ? stage.permissions.some( + ({ role }) => + !serverState.workflow.stages.find( + (stage) => + !!(stage.permissions ?? []).find((permission) => permission.role === role) + ) + ) + : false; + + return { + ...stage, + permissions: hasUpdatedPermissions ? stage.permissions : undefined, + }; + }), + }, + }); return res; } catch (error) { @@ -194,14 +228,37 @@ export function ReviewWorkflowsEditView() { const limits = getFeature('review-workflows'); React.useEffect(() => { - dispatch(setWorkflow({ status: workflowStatus, data: workflow })); + if (!isLoadingWorkflow) { + dispatch(setWorkflow({ workflow })); + dispatch(setWorkflows({ workflows })); + } + + if (!isLoadingContentTypes) { + dispatch(setContentTypes({ collectionTypes, singleTypes })); + } + + if (!isLoadingRoles) { + dispatch(setRoles(serverRoles)); + } + + dispatch(setIsLoading(isLoadingWorkflow || isLoadingContentTypes || isLoadingRoles)); // reset the state to the initial state to avoid flashes if a user // navigates from an edit-view to a create-view return () => { dispatch(resetWorkflow()); }; - }, [workflowStatus, workflow, dispatch]); + }, [ + collectionTypes, + dispatch, + isLoadingContentTypes, + isLoadingWorkflow, + isLoadingRoles, + serverRoles, + singleTypes, + workflow, + workflows, + ]); /** * If the current license has a limit: @@ -217,7 +274,7 @@ export function ReviewWorkflowsEditView() { */ React.useEffect(() => { - if (!isWorkflowLoading && !isLicenseLoading) { + if (!isLoadingWorkflow && !isLicenseLoading) { if ( limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] && meta?.workflowCount > parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10) @@ -234,12 +291,25 @@ export function ReviewWorkflowsEditView() { }, [ currentWorkflow.stages.length, isLicenseLoading, - isWorkflowLoading, + isLoadingWorkflow, limits, meta?.workflowCount, meta.workflowsTotal, ]); + React.useEffect(() => { + if (!isLoading && roles.length === 0) { + toggleNotification({ + blockTransition: true, + type: 'warning', + message: formatMessage({ + id: 'Settings.review-workflows.stage.permissions.noPermissions.description', + defaultMessage: 'You don’t have the permission to see roles', + }), + }); + } + }, [formatMessage, isLoading, roles, toggleNotification]); + // TODO: redirect back to list-view if workflow is not found? return ( @@ -259,7 +329,7 @@ export function ReviewWorkflowsEditView() { disabled={!currentWorkflowIsDirty} // if the confirm dialog is open the loading state is on // the confirm button already - loading={!Object.keys(savePrompts).length > 0 && isLoading} + loading={!Object.keys(savePrompts).length > 0 && isLoadingMutation} > {formatMessage({ id: 'global.save', @@ -269,7 +339,7 @@ export function ReviewWorkflowsEditView() { ) } subtitle={ - currentWorkflow.stages.length > 0 && + !isLoading && formatMessage( { id: 'Settings.review-workflows.page.subtitle', @@ -282,7 +352,7 @@ export function ReviewWorkflowsEditView() { /> - {isLoadingModels || status === 'loading' ? ( + {isLoading ? ( {formatMessage({ @@ -293,12 +363,7 @@ export function ReviewWorkflowsEditView() { ) : ( - + ({ ...stage, // A safety net in case a stage does not have a color assigned; - // this normallly should not happen + // this should not happen color: stage?.color ?? STAGE_COLOR_DEFAULT, })), }; } + break; + } - draft.clientState.currentWorkflow.hasDeletedServerStages = false; + case ACTION_SET_WORKFLOWS: { + draft.serverState.workflows = payload; break; } case ACTION_RESET_WORKFLOW: { - draft.clientState.currentWorkflow.data = initialState.clientState.currentWorkflow.data; + draft.clientState = initialState.clientState; draft.serverState = initialState.serverState; break; } @@ -71,12 +95,6 @@ export function reducer(state = initialState, action) { (stage) => (stage?.id ?? stage.__temp_key__) !== stageId ); - if (!currentWorkflow.hasDeletedServerStages) { - draft.clientState.currentWorkflow.hasDeletedServerStages = !!( - state.serverState.workflow?.stages ?? [] - ).find((stage) => stage.id === stageId); - } - break; } @@ -149,16 +167,6 @@ export function reducer(state = initialState, action) { default: break; } - - if (state.clientState.currentWorkflow.data && draft.serverState.workflow) { - draft.clientState.currentWorkflow.isDirty = !isEqual( - current(draft.clientState.currentWorkflow).data, - draft.serverState.workflow - ); - } else { - // if there is no workflow on the server, the workflow is awalys considered dirty - draft.clientState.currentWorkflow.isDirty = true; - } }); } diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js index 1c4d4d84f5..09f059d08f 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js @@ -3,6 +3,9 @@ import { ACTION_ADD_STAGE, ACTION_DELETE_STAGE, ACTION_RESET_WORKFLOW, + ACTION_SET_CONTENT_TYPES, + ACTION_SET_IS_LOADING, + ACTION_SET_ROLES, ACTION_SET_WORKFLOW, ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE_POSITION, @@ -33,10 +36,57 @@ describe('Admin | Settings | Review Workflows | reducer', () => { state = initialState; }); + test('ACTION_SET_IS_LOADING', () => { + const action = { + type: ACTION_SET_IS_LOADING, + payload: true, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + isLoading: true, + }), + }) + ); + }); + + test('ACTION_SET_CONTENT_TYPES', () => { + const action = { + type: ACTION_SET_CONTENT_TYPES, + payload: { collectionTypes: [{ id: 1 }] }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + contentTypes: { + collectionTypes: [{ id: 1 }], + }, + }), + }) + ); + }); + + test('ACTION_SET_ROLES', () => { + const action = { + type: ACTION_SET_ROLES, + payload: [{ id: 1 }], + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + serverState: expect.objectContaining({ + roles: [{ id: 1 }], + }), + }) + ); + }); + test('ACTION_SET_WORKFLOW with workflows', () => { const action = { type: ACTION_SET_WORKFLOW, - payload: { status: 'loading-state', workflow: WORKFLOW_FIXTURE }, + payload: WORKFLOW_FIXTURE, }; const DEFAULT_WORKFLOW_FIXTURE = { @@ -51,15 +101,12 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect(reducer(state, action)).toStrictEqual( expect.objectContaining({ - status: 'loading-state', serverState: expect.objectContaining({ workflow: WORKFLOW_FIXTURE, }), clientState: expect.objectContaining({ currentWorkflow: expect.objectContaining({ data: DEFAULT_WORKFLOW_FIXTURE, - isDirty: false, - hasDeletedServerStages: false, }), }), }) @@ -78,7 +125,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { workflow: WORKFLOW_FIXTURE, }, clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -95,107 +142,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to true if stageId exists on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - set hasDeletedServerStages to false if stageId does not exist on the server', () => { - const action = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: { - ...WORKFLOW_FIXTURE, - stages: [...WORKFLOW_FIXTURE.stages, { __temp_key__: 3, name: 'something' }], - }, - isDirty: false, - }, - }, - }; - - expect(reducer(state, action)).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: false, - }), - }), - }) - ); - }); - - test('ACTION_DELETE_STAGE - keep hasDeletedServerStages true as soon as one server stage has been deleted', () => { - const actionDeleteServerStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 1 }, - }; - - const actionDeleteClientStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { - data: WORKFLOW_FIXTURE, - isDirty: false, - }, - }, - }; - - state = reducer(state, actionDeleteServerStage); - state = reducer(state, actionDeleteClientStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - hasDeletedServerStages: true, - }), - }), - }) - ); - }); - test('ACTION_ADD_STAGE', () => { const action = { type: ACTION_ADD_STAGE, @@ -206,7 +152,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -239,7 +185,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: null, isDirty: false }, + currentWorkflow: { data: null }, }, }; @@ -287,7 +233,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -320,7 +266,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { status: expect.any(String), serverState: expect.any(Object), clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, + currentWorkflow: { data: WORKFLOW_FIXTURE }, }, }; @@ -343,52 +289,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { ); }); - test('properly compare serverState and clientState and set isDirty accordingly', () => { - const actionAddStage = { - type: ACTION_ADD_STAGE, - payload: { name: 'something' }, - }; - - state = { - status: expect.any(String), - serverState: { - workflow: WORKFLOW_FIXTURE, - }, - clientState: { - currentWorkflow: { data: WORKFLOW_FIXTURE, isDirty: false }, - }, - }; - - state = reducer(state, actionAddStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: true, - }), - }), - }) - ); - - const actionDeleteStage = { - type: ACTION_DELETE_STAGE, - payload: { stageId: 3 }, - }; - - state = reducer(state, actionDeleteStage); - - expect(state).toStrictEqual( - expect.objectContaining({ - clientState: expect.objectContaining({ - currentWorkflow: expect.objectContaining({ - isDirty: false, - }), - }), - }) - ); - }); - test('ACTION_UPDATE_STAGE_POSITION', () => { const action = { type: ACTION_UPDATE_STAGE_POSITION, @@ -403,7 +303,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -418,7 +317,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-1' }), ], }), - isDirty: true, }), }), }) @@ -439,7 +337,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -454,7 +351,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -475,7 +371,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -490,7 +385,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { expect.objectContaining({ name: 'stage-2' }), ], }), - isDirty: false, }), }), }) @@ -511,7 +405,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -523,7 +416,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { data: expect.objectContaining({ name: 'test', }), - isDirty: true, }), }), }) @@ -543,7 +435,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { clientState: { currentWorkflow: { data: WORKFLOW_FIXTURE, - isDirty: false, }, }, }; @@ -556,7 +447,6 @@ describe('Admin | Settings | Review Workflows | reducer', () => { name: '', stages: [], }), - isDirty: true, }), }), }) diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js new file mode 100644 index 0000000000..a65581e83a --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/selectors.js @@ -0,0 +1,45 @@ +import isEqual from 'lodash/isEqual'; +import { createSelector } from 'reselect'; + +import { REDUX_NAMESPACE } from './constants'; +import { initialState } from './reducer'; + +export const selectNamespace = (state) => state[REDUX_NAMESPACE] ?? initialState; + +export const selectContentTypes = createSelector( + selectNamespace, + ({ serverState: { contentTypes } }) => contentTypes +); + +export const selectRoles = createSelector(selectNamespace, ({ serverState: { roles } }) => roles); + +export const selectCurrentWorkflow = createSelector( + selectNamespace, + ({ clientState: { currentWorkflow } }) => currentWorkflow.data +); + +export const selectWorkflows = createSelector( + selectNamespace, + ({ serverState: { workflows } }) => workflows +); + +export const selectIsWorkflowDirty = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !isEqual(serverState.workflow, currentWorkflow.data) +); + +export const selectHasDeletedServerStages = createSelector( + selectNamespace, + ({ serverState, clientState: { currentWorkflow } }) => + !(serverState.workflow?.stages ?? []).every( + (stage) => !!currentWorkflow.data.stages.find(({ id }) => id === stage.id) + ) +); + +export const selectIsLoading = createSelector( + selectNamespace, + ({ clientState: { isLoading } }) => isLoading +); + +export const selectServerState = createSelector(selectNamespace, ({ serverState }) => serverState); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js index 9fe921b9da..f7c65a84d1 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/validateWorkflow.test.js @@ -145,4 +145,79 @@ describe('Settings | Review Workflows | validateWorkflow()', () => { } `); }); + + test('stages.permissions: array', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [{ role: 1, action: 'admin::review-workflow.stage.transition' }], + }, + ], + }) + ).toEqual(true); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: [], + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "Must be either an array or undefined", + }, + ], + } + `); + + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: { role: '1', action: 'admin::review-workflow.stage.transition' }, + }, + ], + }) + ).toMatchInlineSnapshot(` + { + "stages": [ + { + "permissions": "stages[0].permissions must be a \`array\` type, but the final value was: \`{ + "role": "\\"1\\"", + "action": "\\"admin::review-workflow.stage.transition\\"" + }\`.", + }, + ], + } + `); + }); + + test('stages.permissions: undefined', async () => { + expect( + await setup({ + name: 'name', + stages: [ + { + name: 'stage-1', + color: '#ffffff', + permissions: undefined, + }, + ], + }) + ).toEqual(true); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js index cdac8102ce..1c1fc22225 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/validateWorkflow.js @@ -57,6 +57,33 @@ export async function validateWorkflow({ values, formatMessage }) { }) ) .matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), + + permissions: yup + .array( + yup.object({ + role: yup + .number() + .strict() + .typeError( + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions.role.number', + defaultMessage: 'Role must be of type number', + }) + ).required, + action: yup.string().required({ + id: 'Settings.review-workflows.validation.stage.permissions.action.required', + defaultMessage: 'Action is a required argument', + }), + }) + ) + .strict() + .min( + 1, + formatMessage({ + id: 'Settings.review-workflows.validation.stage.permissions', + defaultMessage: 'Must be either an array or undefined', + }) + ), }) ) .min(1), From b2eb37f0b2aaa6d208c4f3285bbc240ce0709e53 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 8 Aug 2023 12:43:54 +0200 Subject: [PATCH 27/32] Fix: Make return values of data-fetching hooks stable --- .../admin/src/hooks/useAdminRoles/index.js | 24 ++++++++++++------ .../src/hooks/useAdminUsers/useAdminUsers.js | 22 ++++++++++------ .../hooks/useContentTypes/useContentTypes.js | 25 +++++++++++++------ .../hooks/useReviewWorkflows.js | 23 +++++++++++------ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js index a0ea4520aa..c934afbbc9 100644 --- a/packages/core/admin/admin/src/hooks/useAdminRoles/index.js +++ b/packages/core/admin/admin/src/hooks/useAdminRoles/index.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useCollator, useFetchClient } from '@strapi/helper-plugin'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; @@ -22,16 +24,24 @@ export const useAdminRoles = (params = {}, queryOptions = {}) => { queryOptions ); - let roles = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const roles = React.useMemo(() => { + let roles = []; - if (id && data) { - roles = [data.data]; - } else if (Array.isArray(data?.data)) { - roles = data.data; - } + if (id && data) { + roles = [data.data]; + } else if (Array.isArray(data?.data)) { + roles = data.data; + } + + return [...roles].sort((a, b) => formatter.compare(a.name, b.name)); + }, [data, id, formatter]); return { - roles: [...roles].sort((a, b) => formatter.compare(a.name, b.name)), + roles, error, isError, isLoading, diff --git a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js index a4ea385bb1..f477e1aa5e 100644 --- a/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js +++ b/packages/core/admin/admin/src/hooks/useAdminUsers/useAdminUsers.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,17 +22,23 @@ export function useAdminUsers(params = {}, queryOptions = {}) { queryOptions ); - let users = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const users = React.useMemo(() => { + if (id && data) { + return [data]; + } else if (Array.isArray(data?.results)) { + return data.results; + } - if (id && data) { - users = [data]; - } else if (Array.isArray(data?.results)) { - users = data.results; - } + return []; + }, [data, id]); return { users, - pagination: data?.pagination ?? null, + pagination: React.useMemo(() => data?.pagination ?? null, [data?.pagination]), isLoading, isError, refetch, diff --git a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js index 52ba5fe4fb..1917f9bc69 100644 --- a/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js +++ b/packages/core/admin/admin/src/hooks/useContentTypes/useContentTypes.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useAPIErrorHandler, useFetchClient, useNotification } from '@strapi/helper-plugin'; import { useQueries } from 'react-query'; @@ -29,16 +31,25 @@ export function useContentTypes() { const [components, contentTypes] = queries; const isLoading = components.isLoading || contentTypes.isLoading; - const collectionTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed - ); - const singleTypes = (contentTypes?.data ?? []).filter( - (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed - ); + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks + const collectionTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); + + const singleTypes = React.useMemo(() => { + return (contentTypes?.data ?? []).filter( + (contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed + ); + }, [contentTypes?.data]); return { isLoading, - components: components?.data ?? [], + components: React.useMemo(() => components?.data ?? [], [components?.data]), collectionTypes, singleTypes, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js index 2cc79fedb5..696dff44bf 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { useFetchClient } from '@strapi/helper-plugin'; import { useQuery } from 'react-query'; @@ -20,18 +22,25 @@ export function useReviewWorkflows(params = {}) { } ); - let workflows = []; + // the return value needs to be memoized, because intantiating + // an empty array as default value would lead to an unstable return + // value, which later on triggers infinite loops if used in the + // dependency arrays of other hooks - if (id && data?.data) { - workflows = [data.data]; - } else if (Array.isArray(data?.data)) { - workflows = data.data; - } + const workflows = React.useMemo(() => { + if (id && data?.data) { + return [data.data]; + } else if (Array.isArray(data?.data)) { + return data.data; + } + + return []; + }, [data?.data, id]); return { // meta contains e.g. the total of all workflows. we can not use // the pagination object here, because the list is not paginated. - meta: data?.meta ?? {}, + meta: React.useMemo(() => data?.meta ?? {}, [data?.meta]), workflows, isLoading, status, From 3b8a10f8022aeba9b0cb3e8fdc511102e767cd73 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 8 Aug 2023 15:42:47 +0200 Subject: [PATCH 28/32] chore: format workflow to have string roles --- .../ee/server/controllers/workflows/index.js | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/core/admin/ee/server/controllers/workflows/index.js b/packages/core/admin/ee/server/controllers/workflows/index.js index 47d651f422..53a477c74e 100644 --- a/packages/core/admin/ee/server/controllers/workflows/index.js +++ b/packages/core/admin/ee/server/controllers/workflows/index.js @@ -1,5 +1,6 @@ 'use strict'; +const { update, map, property } = require('lodash/fp'); const { mapAsync } = require('@strapi/utils'); const { getService } = require('../../utils'); @@ -22,6 +23,19 @@ function getWorkflowsPermissionChecker({ strapi }, userAbility) { .create({ userAbility, model: WORKFLOW_MODEL_UID }); } +/** + * Transforms workflow to an admin UI format. + * Some attributes (like permissions) are presented in a different format in the admin UI. + * @param {Workflow} workflow + */ +function formatWorkflowToAdmin(workflow) { + if (!workflow) return; + // Transform permissions roles to be the id string instead of an object + const transformPermissions = map(update('role', property('id'))); + const transformStages = map(update('permissions', transformPermissions)); + return update('stages', transformStages, workflow); +} + module.exports = { /** * Create a new workflow @@ -38,10 +52,12 @@ module.exports = { const workflowBody = await validateWorkflowCreate(body.data); const workflowService = getService('workflows'); - const createdWorkflow = await workflowService.create({ - data: await sanitizeCreateInput(workflowBody), - populate, - }); + const createdWorkflow = await workflowService + .create({ + data: await sanitizeCreateInput(workflowBody), + populate, + }) + .then(formatWorkflowToAdmin); ctx.body = { data: await sanitizeOutput(createdWorkflow), @@ -74,10 +90,12 @@ module.exports = { const dataToUpdate = await getPermittedFieldToUpdate(workflowBody); // Update workflow - const updatedWorkflow = await workflowService.update(workflow, { - data: dataToUpdate, - populate, - }); + const updatedWorkflow = await workflowService + .update(workflow, { + data: dataToUpdate, + populate, + }) + .then(formatWorkflowToAdmin); // Send sanitized response ctx.body = { @@ -104,7 +122,9 @@ module.exports = { return ctx.notFound("Workflow doesn't exist"); } - const deletedWorkflow = await workflowService.delete(workflow, { populate }); + const deletedWorkflow = await workflowService + .delete(workflow, { populate }) + .then(formatWorkflowToAdmin); ctx.body = { data: await sanitizeOutput(deletedWorkflow), @@ -125,7 +145,7 @@ module.exports = { const { populate, filters, sort } = await sanitizedQuery.read(query); const [workflows, workflowCount] = await Promise.all([ - workflowService.find({ populate, filters, sort }), + workflowService.find({ populate, filters, sort }).then(map(formatWorkflowToAdmin)), workflowService.count(), ]); @@ -154,7 +174,7 @@ module.exports = { const workflowService = getService('workflows'); const [workflow, workflowCount] = await Promise.all([ - workflowService.findById(id, { populate }), + workflowService.findById(id, { populate }).then(formatWorkflowToAdmin), workflowService.count(), ]); From a4714cc2dde961a540988ee645a1fa28d5f88eff Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 8 Aug 2023 16:10:18 +0200 Subject: [PATCH 29/32] fix: ignore action parameter if it is null --- packages/core/permissions/src/engine/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/permissions/src/engine/index.ts b/packages/core/permissions/src/engine/index.ts index 40680228d4..31a70fa465 100644 --- a/packages/core/permissions/src/engine/index.ts +++ b/packages/core/permissions/src/engine/index.ts @@ -94,11 +94,17 @@ const newEngine = (params: EngineParams): Engine => { await state.hooks['before-evaluate.permission'].call(createBeforeEvaluateContext(permission)); - const { action: actionName, subject, properties, conditions = [], actionParameters = {} } = permission; + const { + action: actionName, + subject, + properties, + conditions = [], + actionParameters = {}, + } = permission; let action = actionName; - if (Object.keys(actionParameters).length > 0) { + if (actionParameters && Object.keys(actionParameters).length > 0) { action = `${actionName}?${qs.stringify(actionParameters)}`; } From 8a27b70a012864fae1943dde6c8a652227f39557 Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Tue, 8 Aug 2023 16:40:32 +0200 Subject: [PATCH 30/32] fix: parametrized permission action unit tests --- .../server/services/__tests__/stages.test.js | 6 ++ .../services/__tests__/workflows.test.js | 16 ++++-- .../services/review-workflows/stages.js | 1 - .../services/__tests__/content-type.test.js | 8 +++ .../services/__tests__/permission.test.js | 6 ++ .../server/services/__tests__/role.test.js | 57 +++++++++++++++++-- 6 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/core/admin/ee/server/services/__tests__/stages.test.js b/packages/core/admin/ee/server/services/__tests__/stages.test.js index 0d88e4b4f6..132f105d72 100644 --- a/packages/core/admin/ee/server/services/__tests__/stages.test.js +++ b/packages/core/admin/ee/server/services/__tests__/stages.test.js @@ -78,6 +78,12 @@ const servicesMock = { validateWorkflowCount: jest.fn().mockResolvedValue(true), validateWorkflowStages: jest.fn(), }, + 'admin::stage-permissions': { + register: jest.fn(), + registerMany: jest.fn(), + unregister: jest.fn(), + can: jest.fn(() => true), + }, }; const queryUpdateMock = jest.fn(() => Promise.resolve()); diff --git a/packages/core/admin/ee/server/services/__tests__/workflows.test.js b/packages/core/admin/ee/server/services/__tests__/workflows.test.js index 38bd334e64..26f4f6cc0e 100644 --- a/packages/core/admin/ee/server/services/__tests__/workflows.test.js +++ b/packages/core/admin/ee/server/services/__tests__/workflows.test.js @@ -24,7 +24,7 @@ jest.mock('../review-workflows/workflows/content-types', () => { }); const workflowsServiceFactory = require('../review-workflows/workflows'); -const { WORKFLOW_MODEL_UID } = require('../../constants/workflows'); +const { WORKFLOW_MODEL_UID, WORKFLOW_POPULATE } = require('../../constants/workflows'); const workflowMock = { id: 1, @@ -71,6 +71,12 @@ const servicesMock = { replaceStages: jest.fn(async () => stagesMock), createMany: jest.fn(async () => stagesMock), }, + 'admin::stage-permissions': { + register: jest.fn(), + registerMany: jest.fn(), + unregister: jest.fn(), + can: jest.fn(() => true), + }, }; const strapiMock = { @@ -145,7 +151,7 @@ describe('Review workflows - Workflows service', () => { return stage; }), }, - populate: undefined, + populate: WORKFLOW_POPULATE, }; test('Should call entityService with the right model UID', async () => { @@ -158,7 +164,7 @@ describe('Review workflows - Workflows service', () => { name: 'Default', stages: workflow.stages.map((stage) => stage.id), }, - populate: undefined, + populate: WORKFLOW_POPULATE, }); expect(servicesMock['admin::review-workflows-metrics'].sendDidEditWorkflow).toBeCalled(); }); @@ -177,7 +183,7 @@ describe('Review workflows - Workflows service', () => { }, ], }, - populate: undefined, + populate: WORKFLOW_POPULATE, }; test('Should call entityService with the right model UID', async () => { @@ -189,7 +195,7 @@ describe('Review workflows - Workflows service', () => { name: 'Workflow', stages: stagesMock.map((stage) => stage.id), }, - populate: { stages: true }, + populate: WORKFLOW_POPULATE, }); expect(servicesMock['admin::review-workflows-metrics'].sendDidCreateWorkflow).toBeCalled(); }); diff --git a/packages/core/admin/ee/server/services/review-workflows/stages.js b/packages/core/admin/ee/server/services/review-workflows/stages.js index fd6f7fa089..e72a9a67d1 100644 --- a/packages/core/admin/ee/server/services/review-workflows/stages.js +++ b/packages/core/admin/ee/server/services/review-workflows/stages.js @@ -81,7 +81,6 @@ module.exports = ({ strapi }) => { let stagePermissions = []; const stageId = destStage.id; - // TODO: Do not delete permissions if they are not changed await this.deleteStagePermissions([srcStage]); if (destStage.permissions) { diff --git a/packages/core/admin/server/services/__tests__/content-type.test.js b/packages/core/admin/server/services/__tests__/content-type.test.js index 405b033e53..631b431f80 100644 --- a/packages/core/admin/server/services/__tests__/content-type.test.js +++ b/packages/core/admin/server/services/__tests__/content-type.test.js @@ -232,6 +232,7 @@ describe('Content-Type', () => { expect(resultLevel1).toEqual([ { action: 'action-1', + actionParameters: {}, subject: 'country', properties: { fields: ['name', 'code'] }, conditions: [], @@ -253,12 +254,14 @@ describe('Content-Type', () => { expect(resultLevel1).toEqual([ { action: 'action-1', + actionParameters: {}, subject: 'country', properties: { fields: ['name', 'code'] }, conditions: [], }, { action: 'action-1', + actionParameters: {}, subject: 'user', properties: { fields: ['firstname', 'restaurant', 'car'] }, conditions: [], @@ -280,12 +283,14 @@ describe('Content-Type', () => { expect(resultLevel1).toEqual([ { action: 'action-1', + actionParameters: {}, subject: 'country', properties: { fields: ['name', 'code'] }, conditions: [], }, { action: 'action-1', + actionParameters: {}, subject: 'user', properties: { fields: [ @@ -312,12 +317,14 @@ describe('Content-Type', () => { expect(resultLevel1).toEqual([ { action: 'action-1', + actionParameters: {}, subject: 'country', properties: { fields: ['name', 'code'] }, conditions: [], }, { action: 'action-1', + actionParameters: {}, subject: 'user', properties: { fields: [ @@ -387,6 +394,7 @@ describe('Content-Type', () => { expect(res).toEqual([ { action: 'foo', + actionParameters: {}, subject: 'user', properties: { fields: expectedFields }, conditions: [], diff --git a/packages/core/admin/server/services/__tests__/permission.test.js b/packages/core/admin/server/services/__tests__/permission.test.js index d771256fcd..c0649a1200 100644 --- a/packages/core/admin/server/services/__tests__/permission.test.js +++ b/packages/core/admin/server/services/__tests__/permission.test.js @@ -85,34 +85,40 @@ describe('Permission Service', () => { { id: 1, action: 'action-1', + actionParameters: {}, properties: { fields: ['name'] }, }, { id: 2, action: 'action-2', + actionParameters: {}, properties: { fields: ['name'] }, }, { id: 3, action: 'action-3', + actionParameters: {}, subject: 'country', properties: { fields: ['name'] }, }, { id: 4, action: 'action-3', + actionParameters: {}, subject: 'planet', properties: { fields: ['name'] }, }, { id: 5, action: 'action-1', + actionParameters: {}, subject: 'planet', properties: { fields: ['name', 'description'] }, }, { id: 6, action: 'action-1', + actionParameters: {}, subject: 'country', properties: { fields: null }, }, diff --git a/packages/core/admin/server/services/__tests__/role.test.js b/packages/core/admin/server/services/__tests__/role.test.js index 94965deb4b..a216266362 100644 --- a/packages/core/admin/server/services/__tests__/role.test.js +++ b/packages/core/admin/server/services/__tests__/role.test.js @@ -404,6 +404,7 @@ describe('Role', () => { const permissions = [ { action: 'action-1', + actionParameters: {}, subject: 'country', properties: { fields: ['name'] }, conditions: [], @@ -413,36 +414,42 @@ describe('Role', () => { const defaultPermissions = [ { action: 'plugin::upload.read', + actionParameters: {}, conditions: ['admin::is-creator'], properties: {}, subject: null, }, { action: 'plugin::upload.configure-view', + actionParameters: {}, conditions: [], properties: {}, subject: null, }, { action: 'plugin::upload.assets.create', + actionParameters: {}, conditions: [], properties: {}, subject: null, }, { action: 'plugin::upload.assets.update', + actionParameters: {}, conditions: ['admin::is-creator'], properties: {}, subject: null, }, { action: 'plugin::upload.assets.download', + actionParameters: {}, conditions: [], properties: {}, subject: null, }, { action: 'plugin::upload.assets.copy-link', + actionParameters: {}, conditions: [], properties: {}, subject: null, @@ -625,24 +632,28 @@ describe('Role', () => { const permissions = [ { action: 'action-1', + actionParameters: {}, subject: 'country', properties: { fields: ['name'] }, conditions: [], }, { action: 'action-test2', + actionParameters: {}, subject: 'test-subject1', properties: {}, conditions: [], }, { action: 'action-test2', + actionParameters: {}, subject: 'test-subject2', properties: {}, conditions: [], }, { action: 'action-test3', + actionParameters: {}, subject: null, properties: {}, conditions: [], @@ -759,11 +770,46 @@ describe('Role', () => { expect(createMany).toHaveBeenCalledTimes(1); expect(createMany).toHaveBeenCalledWith([ - { action: 'action-0', conditions: [], properties: {}, role: 1, subject: null }, - { action: 'action-1', conditions: [], properties: {}, role: 1, subject: null }, - { action: 'action-2', conditions: [], properties: {}, role: 1, subject: null }, - { action: 'action-3', conditions: [], properties: {}, role: 1, subject: null }, - { action: 'action-4', conditions: ['cond'], properties: {}, role: 1, subject: null }, + { + action: 'action-0', + actionParameters: {}, + conditions: [], + properties: {}, + role: 1, + subject: null, + }, + { + action: 'action-1', + actionParameters: {}, + conditions: [], + properties: {}, + role: 1, + subject: null, + }, + { + action: 'action-2', + actionParameters: {}, + conditions: [], + properties: {}, + role: 1, + subject: null, + }, + { + action: 'action-3', + actionParameters: {}, + conditions: [], + properties: {}, + role: 1, + subject: null, + }, + { + action: 'action-4', + actionParameters: {}, + conditions: ['cond'], + properties: {}, + role: 1, + subject: null, + }, ]); }); }); @@ -775,6 +821,7 @@ describe('Role', () => { const permissions = [ { action: 'someAction', + actionParameters: {}, conditions: [], properties: { fields: null }, subject: null, From a5705063a2312a4f1712c5441daeeb38f41cc03e Mon Sep 17 00:00:00 2001 From: Marc-Roig Date: Wed, 9 Aug 2023 10:44:43 +0200 Subject: [PATCH 31/32] fix: workflow tests --- api-tests/core/admin/ee/review-workflows.test.api.js | 11 ++++++++--- .../admin/ee/server/controllers/workflows/index.js | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/api-tests/core/admin/ee/review-workflows.test.api.js b/api-tests/core/admin/ee/review-workflows.test.api.js index ca2bfcdc0d..af54f5b5ce 100644 --- a/api-tests/core/admin/ee/review-workflows.test.api.js +++ b/api-tests/core/admin/ee/review-workflows.test.api.js @@ -1,5 +1,6 @@ 'use strict'; +const { omit } = require('lodash/fp'); const { mapAsync } = require('@strapi/utils'); const { createStrapiInstance } = require('api-tests/strapi'); @@ -439,11 +440,15 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { expect(workflowRes.status).toBe(200); expect(workflowRes.body.data).toBeInstanceOf(Object); expect(workflowRes.body.data.stages).toBeInstanceOf(Array); - expect(workflowRes.body.data.stages[0]).toMatchObject(stagesUpdateData[0]); - expect(workflowRes.body.data.stages[1]).toMatchObject(stagesUpdateData[1]); + expect(workflowRes.body.data.stages[0]).toMatchObject( + omit(['updatedAt'], stagesUpdateData[0]) + ); + expect(workflowRes.body.data.stages[1]).toMatchObject( + omit(['updatedAt'], stagesUpdateData[1]) + ); expect(workflowRes.body.data.stages[2]).toMatchObject({ id: expect.any(Number), - ...stagesUpdateData[2], + ...omit(['updatedAt'], stagesUpdateData[2]), }); } else { expect(workflowRes.status).toBe(404); diff --git a/packages/core/admin/ee/server/controllers/workflows/index.js b/packages/core/admin/ee/server/controllers/workflows/index.js index 53a477c74e..9df9f61ac9 100644 --- a/packages/core/admin/ee/server/controllers/workflows/index.js +++ b/packages/core/admin/ee/server/controllers/workflows/index.js @@ -30,6 +30,8 @@ function getWorkflowsPermissionChecker({ strapi }, userAbility) { */ function formatWorkflowToAdmin(workflow) { if (!workflow) return; + if (!workflow.stages) return workflow; + // Transform permissions roles to be the id string instead of an object const transformPermissions = map(update('role', property('id'))); const transformStages = map(update('permissions', transformPermissions)); From ec1176b582ab3131e859271f4a3fd4933154c84f Mon Sep 17 00:00:00 2001 From: Jamie Howard Date: Mon, 14 Aug 2023 15:12:48 +0100 Subject: [PATCH 32/32] chore: move stage transition UID to constants --- .../core/admin/ee/server/constants/workflows.js | 13 +++++++------ .../ee/server/controllers/workflows/stages/index.js | 8 ++++++-- .../services/review-workflows/stage-permissions.js | 4 ++-- .../admin/ee/server/validation/review-workflows.js | 3 ++- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/core/admin/ee/server/constants/workflows.js b/packages/core/admin/ee/server/constants/workflows.js index 733a97a0e3..02fafb9aab 100644 --- a/packages/core/admin/ee/server/constants/workflows.js +++ b/packages/core/admin/ee/server/constants/workflows.js @@ -4,6 +4,7 @@ module.exports = { WORKFLOW_MODEL_UID: 'admin::workflow', STAGE_MODEL_UID: 'admin::workflow-stage', + STAGE_TRANSITION_UID: 'admin::review-workflows.stage.transition', STAGE_DEFAULT_COLOR: '#4945FF', ENTITY_STAGE_ATTRIBUTE: 'strapi_stage', MAX_WORKFLOWS: 200, @@ -20,12 +21,12 @@ module.exports = { stages: { populate: { permissions: { - fields: ["action", "actionParameters"], + fields: ['action', 'actionParameters'], populate: { - role: { fields: ["id", "name"] }, - } - } - } - } + role: { fields: ['id', 'name'] }, + }, + }, + }, + }, }, }; diff --git a/packages/core/admin/ee/server/controllers/workflows/stages/index.js b/packages/core/admin/ee/server/controllers/workflows/stages/index.js index c2913a6086..56660f6f85 100644 --- a/packages/core/admin/ee/server/controllers/workflows/stages/index.js +++ b/packages/core/admin/ee/server/controllers/workflows/stages/index.js @@ -3,7 +3,11 @@ const { mapAsync } = require('@strapi/utils'); const { getService } = require('../../../utils'); const { validateUpdateStageOnEntity } = require('../../../validation/review-workflows'); -const { STAGE_MODEL_UID, ENTITY_STAGE_ATTRIBUTE } = require('../../../constants/workflows'); +const { + STAGE_MODEL_UID, + ENTITY_STAGE_ATTRIBUTE, + STAGE_TRANSITION_UID, +} = require('../../../constants/workflows'); /** * @@ -97,7 +101,7 @@ module.exports = { // Validate if entity stage can be updated const canTransition = stagePermissions.can( - 'admin::review-workflows.stage.transition', + STAGE_TRANSITION_UID, entity[ENTITY_STAGE_ATTRIBUTE]?.id ); diff --git a/packages/core/admin/ee/server/services/review-workflows/stage-permissions.js b/packages/core/admin/ee/server/services/review-workflows/stage-permissions.js index bfef894733..15035c74eb 100644 --- a/packages/core/admin/ee/server/services/review-workflows/stage-permissions.js +++ b/packages/core/admin/ee/server/services/review-workflows/stage-permissions.js @@ -6,9 +6,9 @@ const { errors: { ApplicationError }, } = require('@strapi/utils'); const { getService } = require('../../utils'); +const { STAGE_TRANSITION_UID } = require('../../constants/workflows'); -// TODO: This should use constants -const validActions = ['admin::review-workflows.stage.transition']; +const validActions = [STAGE_TRANSITION_UID]; module.exports = ({ strapi }) => { const roleService = getService('role'); diff --git a/packages/core/admin/ee/server/validation/review-workflows.js b/packages/core/admin/ee/server/validation/review-workflows.js index bb7fb6bbfe..7a779d6309 100644 --- a/packages/core/admin/ee/server/validation/review-workflows.js +++ b/packages/core/admin/ee/server/validation/review-workflows.js @@ -4,6 +4,7 @@ const { yup, validateYupSchema } = require('@strapi/utils'); const { hasStageAttribute } = require('../utils/review-workflows'); +const { STAGE_TRANSITION_UID } = require('../constants/workflows'); const stageObject = yup.object().shape({ id: yup.number().integer().min(1), @@ -12,7 +13,7 @@ const stageObject = yup.object().shape({ permissions: yup.array().of( yup.object().shape({ role: yup.number().integer().min(1).required(), - action: yup.string().oneOf(['admin::review-workflows.stage.transition']).required(), + action: yup.string().oneOf([STAGE_TRANSITION_UID]).required(), actionParameters: yup.object().shape({ from: yup.number().integer().min(1).required(), to: yup.number().integer().min(1),