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 4246c9aec9..d76f174816 100644 --- a/api-tests/core/admin/ee/review-workflows.test.api.js +++ b/api-tests/core/admin/ee/review-workflows.test.api.js @@ -279,10 +279,25 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { ]; }); + test("It should assign a default color to stages if they don't have one", async () => { + await requests.admin.put(`/admin/review-workflows/workflows/${testWorkflow.id}/stages`, { + body: { + data: [defaultStage, { id: secondStage.id, name: secondStage.name, color: '#000000' }], + }, + }); + + const workflowRes = await requests.admin.get( + `/admin/review-workflows/workflows/${testWorkflow.id}?populate=*` + ); + + expect(workflowRes.status).toBe(200); + expect(workflowRes.body.data.stages[0].color).toBe('#4945FF'); + expect(workflowRes.body.data.stages[1].color).toBe('#000000'); + }); test("It shouldn't be available for public", async () => { const stagesRes = await requests.public.put( `/admin/review-workflows/workflows/${testWorkflow.id}/stages`, - stagesUpdateData + { body: { data: stagesUpdateData } } ); const workflowRes = await requests.public.get( `/admin/review-workflows/workflows/${testWorkflow.id}` @@ -353,6 +368,19 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { expect(workflowRes.body.data).toBeUndefined(); } }); + test('It should throw an error if trying to create more than 200 stages', async () => { + const stagesRes = await requests.admin.put( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages`, + { body: { data: Array(201).fill({ name: 'new stage' }) } } + ); + + if (hasRW) { + expect(stagesRes.status).toBe(400); + expect(stagesRes.body.error).toBeDefined(); + expect(stagesRes.body.error.name).toEqual('ValidationError'); + expect(stagesRes.body.error.message).toBeDefined(); + } + }); }); describe('Enabling/Disabling review workflows on a content type', () => { @@ -416,7 +444,7 @@ describeOnCondition(edition === 'EE')('Review workflows', () => { }); }); - describe('update a stage on an entity', () => { + describe('Update a stage on an entity', () => { describe('Review Workflow is enabled', () => { beforeAll(async () => { await updateContentType(productUID, { diff --git a/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/ReviewWorkflowsStageEE.js b/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/ReviewWorkflowsStageEE.js index 28683acf4c..7904b05f0d 100644 --- a/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/ReviewWorkflowsStageEE.js +++ b/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/ReviewWorkflowsStageEE.js @@ -1,15 +1,21 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Typography } from '@strapi/design-system'; +import { Box, Flex, Typography } from '@strapi/design-system'; +import { pxToRem } from '@strapi/helper-plugin'; -export function ReviewWorkflowsStageEE({ name }) { +export function ReviewWorkflowsStageEE({ color, name }) { return ( - - {name} - + + + + + {name} + + ); } ReviewWorkflowsStageEE.propTypes = { + color: PropTypes.string.isRequired, name: PropTypes.string.isRequired, }; diff --git a/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/getTableColumn.js b/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/getTableColumn.js index 1390d99bb9..1f52c0af40 100644 --- a/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/getTableColumn.js +++ b/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/getTableColumn.js @@ -4,6 +4,7 @@ import { Typography } from '@strapi/design-system'; import ReviewWorkflowsStage from '.'; import getTrad from '../../../../../../../admin/src/content-manager/utils/getTrad'; +import { STAGE_COLOR_DEFAULT } from '../../../../../pages/SettingsPage/pages/ReviewWorkflows/constants'; export default (layout) => { const { formatMessage } = useIntl(); @@ -24,7 +25,7 @@ export default (layout) => { key: '__strapi_reviewWorkflows_stage_temp_key__', name: 'strapi_reviewWorkflows_stage', fieldSchema: { - type: 'custom', + type: 'relation', }, metadatas: { label: formatMessage({ @@ -32,7 +33,8 @@ export default (layout) => { defaultMessage: 'Review stage', }), searchable: false, - sortable: false, + sortable: true, + mainField: 'name', }, cellFormatter({ strapi_reviewWorkflows_stage }) { // if entities are created e.g. through lifecycle methods @@ -41,7 +43,9 @@ export default (layout) => { return -; } - return ; + const { color, name } = strapi_reviewWorkflows_stage; + + return ; }, }; }; diff --git a/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/tests/ReviewWorkflowsStage.test.js b/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/tests/ReviewWorkflowsStage.test.js index 8d17245507..36a9e30e59 100644 --- a/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/tests/ReviewWorkflowsStage.test.js +++ b/packages/core/admin/ee/admin/content-manager/components/DynamicTable/CellContent/ReviewWorkflowsStage/tests/ReviewWorkflowsStage.test.js @@ -17,7 +17,7 @@ const setup = (props) => render(); describe('DynamicTable | ReviewWorkflowsStage', () => { test('render stage name', () => { - const { getByText } = setup({ name: 'reviewed' }); + const { getByText } = setup({ color: 'red', name: 'reviewed' }); expect(getByText('reviewed')).toBeInTheDocument(); }); diff --git a/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/InformationBoxEE.js b/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/InformationBoxEE.js index c5235c0862..b6440c9742 100644 --- a/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/InformationBoxEE.js +++ b/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/InformationBoxEE.js @@ -11,6 +11,8 @@ import { useIntl } from 'react-intl'; import { useMutation } from 'react-query'; import { useReviewWorkflows } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows'; +import { OptionColor } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor'; +import { SingleValueColor } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor'; import Information from '../../../../../../admin/src/content-manager/pages/EditView/Information'; const ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage'; @@ -113,6 +115,8 @@ export function InformationBoxEE() { , + Option: OptionColor, + SingleValue: SingleValueColor, }} error={formattedError} inputId={ATTRIBUTE_NAME} @@ -122,9 +126,19 @@ export function InformationBoxEE() { name={ATTRIBUTE_NAME} onChange={handleStageChange} options={ - workflow ? workflow.stages.map(({ id, name }) => ({ value: id, label: name })) : [] + workflow + ? workflow.stages.map(({ id, color, name }) => ({ + value: id, + label: name, + color, + })) + : [] } - value={{ value: activeWorkflowStage?.id, label: activeWorkflowStage?.name }} + value={{ + value: activeWorkflowStage?.id, + label: activeWorkflowStage?.name, + color: activeWorkflowStage?.color, + }} /> diff --git a/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/tests/InformationBoxEE.test.js b/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/tests/InformationBoxEE.test.js index c480d80817..f433fcafa0 100644 --- a/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/tests/InformationBoxEE.test.js +++ b/packages/core/admin/ee/admin/content-manager/pages/EditView/InformationBox/tests/InformationBoxEE.test.js @@ -12,6 +12,7 @@ import { InformationBoxEE } from '../InformationBoxEE'; const STAGE_ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage'; const STAGE_FIXTURE = { id: 1, + color: 'red', name: 'Stage 1', worklow: 1, }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js index 44e729e23b..af6419b078 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js @@ -18,12 +18,24 @@ import { Check } from '@strapi/icons'; import { Stages } from './components/Stages'; import { reducer, initialState } from './reducer'; -import { REDUX_NAMESPACE } from './constants'; +import { REDUX_NAMESPACE, DRAG_DROP_TYPES } from './constants'; import { useInjectReducer } from '../../../../../../admin/src/hooks/useInjectReducer'; import { useReviewWorkflows } from './hooks/useReviewWorkflows'; import { setWorkflows } from './actions'; import { getWorkflowValidationSchema } from './utils/getWorkflowValidationSchema'; import adminPermissions from '../../../../../../admin/src/permissions'; +import { StageDragPreview } from './components/StageDragPreview'; +import { DragLayer } from '../../../../../../admin/src/components/DragLayer'; + +function renderDragLayerItem({ type, item }) { + switch (type) { + case DRAG_DROP_TYPES.STAGE: + return ; + + default: + return null; + } +} export function ReviewWorkflowsPage() { const { trackUsage } = useTracking(); @@ -47,31 +59,15 @@ export function ReviewWorkflowsPage() { const { mutateAsync, isLoading } = useMutation( async ({ workflowId, stages }) => { - try { - const { - data: { data }, - } = await put(`/admin/review-workflows/workflows/${workflowId}/stages`, { - data: stages, - }); + const { + data: { data }, + } = await put(`/admin/review-workflows/workflows/${workflowId}/stages`, { + data: stages, + }); - return data; - } catch (error) { - toggleNotification({ - type: 'warning', - message: formatAPIError(error), - }); - } - - return null; + return data; }, { - onError(error) { - toggleNotification({ - type: 'warning', - message: formatAPIError(error), - }); - }, - onSuccess() { toggleNotification({ type: 'success', @@ -81,8 +77,19 @@ export function ReviewWorkflowsPage() { } ); - const updateWorkflowStages = (workflowId, stages) => { - return mutateAsync({ workflowId, stages }); + const updateWorkflowStages = async (workflowId, stages) => { + try { + const res = await mutateAsync({ workflowId, stages }); + + return res; + } catch (error) { + toggleNotification({ + type: 'warning', + message: formatAPIError(error), + }); + + return null; + } }; const submitForm = async () => { @@ -135,6 +142,8 @@ export function ReviewWorkflowsPage() { })} />
+ +
theme.colors.neutral600}; + } +`; + +export function StageDragPreview({ name }) { + return ( + + + + + + {name} + + ); +} + +StageDragPreview.propTypes = { + name: PropTypes.string.isRequired, +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/index.js new file mode 100644 index 0000000000..a771750e68 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/index.js @@ -0,0 +1 @@ +export * from './StageDragPreview'; 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 1b8900e739..c7447b96b3 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 @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import * as React from 'react'; import PropTypes from 'prop-types'; import { useField } from 'formik'; import { useIntl } from 'react-intl'; @@ -7,84 +7,308 @@ import { Accordion, AccordionToggle, AccordionContent, + Box, + Field, + FieldLabel, + FieldError, + Flex, Grid, GridItem, IconButton, TextInput, + VisuallyHidden, } from '@strapi/design-system'; -import { useTracking } from '@strapi/helper-plugin'; -import { Trash } from '@strapi/icons'; +import { ReactSelect, useTracking } from '@strapi/helper-plugin'; +import { Drag, Trash } from '@strapi/icons'; -import { deleteStage, updateStage } from '../../../actions'; +import { deleteStage, updateStagePosition, updateStage } from '../../../actions'; +import { getAvailableStageColors } from '../../../utils/colors'; +import { OptionColor } from './components/OptionColor'; +import { SingleValueColor } from './components/SingleValueColor'; +import { useDragAndDrop } from '../../../../../../../../../admin/src/content-manager/hooks'; +import { composeRefs } from '../../../../../../../../../admin/src/content-manager/utils'; +import { DRAG_DROP_TYPES } from '../../../constants'; -function Stage({ id, name, index, canDelete, isOpen: isOpenDefault = false }) { - const { formatMessage } = useIntl(); - const { trackUsage } = useTracking(); - const [isOpen, setIsOpen] = useState(isOpenDefault); - const fieldIdentifier = `stages.${index}.name`; - const [field, meta] = useField(fieldIdentifier); - const dispatch = useDispatch(); +const AVAILABLE_COLORS = getAvailableStageColors(); +function StageDropPreview() { return ( - { - setIsOpen(!isOpen); - - if (!isOpen) { - trackUsage('willEditStage'); - } - }} - expanded={isOpen} + - dispatch(deleteStage(id))} - label={formatMessage({ - id: 'Settings.review-workflows.stage.delete', - defaultMessage: 'Delete stage', - })} - icon={} - /> - ) : null - } - /> - - - - { - field.onChange(event); - dispatch(updateStage(id, { name: event.target.value })); - }} - /> - - - - + /> ); } -export { Stage }; +export function Stage({ + id, + index, + canDelete, + canReorder, + isOpen: isOpenDefault = false, + stagesCount, +}) { + /** + * + * @param {number} index + * @returns {string} + */ + const getItemPos = (index) => `${index + 1} of ${stagesCount}`; + + /** + * + * @param {number} index + * @returns {void} + */ + const handleGrabStage = (index) => { + setLiveText( + formatMessage( + { + id: 'dnd.grab-item', + defaultMessage: `{item}, grabbed. Current position in list: {position}. Press up and down arrow to change position, Spacebar to drop, Escape to cancel.`, + }, + { + item: nameField.value, + position: getItemPos(index), + } + ) + ); + }; + + /** + * + * @param {number} index + * @returns {void} + */ + const handleDropStage = (index) => { + setLiveText( + formatMessage( + { + id: 'dnd.drop-item', + defaultMessage: `{item}, dropped. Final position in list: {position}.`, + }, + { + item: nameField.value, + position: getItemPos(index), + } + ) + ); + }; + + /** + * + * @param {number} index + * @returns {void} + */ + const handleCancelDragStage = () => { + setLiveText( + formatMessage( + { + id: 'dnd.cancel-item', + defaultMessage: '{item}, dropped. Re-order cancelled.', + }, + { + item: nameField.value, + } + ) + ); + }; + + const handleMoveStage = (newIndex, oldIndex) => { + setLiveText( + formatMessage( + { + id: 'dnd.reorder', + defaultMessage: '{item}, moved. New position in list: {position}.', + }, + { + item: nameField.value, + position: getItemPos(newIndex), + } + ) + ); + + dispatch(updateStagePosition(oldIndex, newIndex)); + }; + + const [liveText, setLiveText] = React.useState(null); + const { formatMessage } = useIntl(); + const { trackUsage } = useTracking(); + const dispatch = useDispatch(); + const [isOpen, setIsOpen] = React.useState(isOpenDefault); + const [nameField, nameMeta] = useField(`stages.${index}.name`); + const [colorField, colorMeta] = useField(`stages.${index}.color`); + const [{ handlerId, isDragging, handleKeyDown }, stageRef, dropRef, dragRef] = useDragAndDrop( + canReorder, + { + index, + item: { + name: nameField.value, + }, + onGrabItem: handleGrabStage, + onDropItem: handleDropStage, + onMoveItem: handleMoveStage, + onCancel: handleCancelDragStage, + type: DRAG_DROP_TYPES.STAGE, + } + ); + + const composedRef = composeRefs(stageRef, dropRef); + + const colorOptions = AVAILABLE_COLORS.map(({ hex, name }) => ({ + value: hex, + label: formatMessage( + { + id: 'Settings.review-workflows.stage.color.name', + defaultMessage: '{name}', + }, + { name } + ), + color: hex, + })); + // TODO: the .toUpperCase() conversion can be removed once the hex code is normalized in + // the admin API + const colorValue = colorOptions.find(({ value }) => value === colorField.value.toUpperCase()); + + return ( + + {liveText && {liveText}} + + {isDragging ? ( + + ) : ( + { + setIsOpen(!isOpen); + + if (!isOpen) { + trackUsage('willEditStage'); + } + }} + expanded={isOpen} + shadow="tableShadow" + > + + {canDelete && ( + } + label={formatMessage({ + id: 'Settings.review-workflows.stage.delete', + defaultMessage: 'Delete stage', + })} + noBorder + onClick={() => dispatch(deleteStage(id))} + /> + )} + + e.stopPropagation()} + onKeyDown={handleKeyDown} + > + + + + } + /> + + + + { + nameField.onChange(event); + dispatch(updateStage(id, { name: event.target.value })); + }} + required + /> + + + + + + + {formatMessage({ + id: 'content-manager.reviewWorkflows.stage.color', + defaultMessage: 'Color', + })} + + + { + colorField.onChange({ target: { value } }); + dispatch(updateStage(id, { color: value })); + }} + // If no color was found in all the valid theme colors it means a user + // has set a custom value e.g. through the content API. In that case we + // display the custom color and a "Custom" label. + value={ + colorValue ?? { + value: colorField.value, + label: formatMessage({ + id: 'Settings.review-workflows.stage.color.name.custom', + defaultMessage: 'Custom', + }), + color: colorField.value, + } + } + /> + + + + + + + + + )} + + ); +} Stage.propTypes = PropTypes.shape({ id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, + color: PropTypes.string.isRequired, canDelete: PropTypes.bool.isRequired, + canReorder: PropTypes.bool.isRequired, + stagesCount: PropTypes.number.isRequired, }).isRequired; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor/OptionColor.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor/OptionColor.js new file mode 100644 index 0000000000..6285e4982a --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor/OptionColor.js @@ -0,0 +1,27 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { components } from 'react-select'; +import { Flex, Typography } from '@strapi/design-system'; + +export function OptionColor({ children, ...props }) { + const { color } = props.data; + + return ( + + + + + + {children} + + + + ); +} + +OptionColor.propTypes = { + children: PropTypes.node.isRequired, + data: PropTypes.shape({ + color: PropTypes.string, + }).isRequired, +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor/index.js new file mode 100644 index 0000000000..492d0cce23 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor/index.js @@ -0,0 +1 @@ +export * from './OptionColor'; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor/SingleValueColor.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor/SingleValueColor.js new file mode 100644 index 0000000000..c3346aac39 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor/SingleValueColor.js @@ -0,0 +1,31 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { components } from 'react-select'; +import { Flex, Typography } from '@strapi/design-system'; + +export function SingleValueColor({ children, ...props }) { + const { color } = props.data; + + return ( + + + + + + {children} + + + + ); +} + +SingleValueColor.defaultProps = { + children: null, +}; + +SingleValueColor.propTypes = { + children: PropTypes.node, + data: PropTypes.shape({ + color: PropTypes.string, + }).isRequired, +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor/index.js new file mode 100644 index 0000000000..e19756824f --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor/index.js @@ -0,0 +1 @@ +export * from './SingleValueColor'; 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 3a216c561d..2607a5f2d3 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 @@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; import { FormikProvider, useFormik } from 'formik'; import { Provider } from 'react-redux'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; import { ThemeProvider, lightTheme } from '@strapi/design-system'; @@ -11,6 +13,8 @@ import configureStore from '../../../../../../../../../../admin/src/core/store/c import { Stage } from '../Stage'; import { reducer } from '../../../../reducer'; +import { STAGE_COLOR_DEFAULT } from '../../../../constants'; + jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }), @@ -18,8 +22,7 @@ jest.mock('@strapi/helper-plugin', () => ({ const STAGES_FIXTURE = { id: 1, - name: 'stage-1', - index: 1, + index: 0, }; const ComponentFixture = (props) => { @@ -30,6 +33,7 @@ const ComponentFixture = (props) => { initialValues: { stages: [ { + color: STAGE_COLOR_DEFAULT, name: 'something', }, ], @@ -38,15 +42,17 @@ const ComponentFixture = (props) => { }); return ( - - - - - - - - - + + + + + + + + + + + ); }; @@ -60,15 +66,20 @@ describe('Admin | Settings | Review Workflow | Stage', () => { }); it('should render a stage', async () => { - const { getByRole, queryByRole } = setup(); + const { container, getByRole, getByText, queryByRole } = setup(); expect(queryByRole('textbox')).not.toBeInTheDocument(); - await user.click(getByRole('button')); + // open accordion; getByRole is not sufficient here, because the accordion + // does not have better identifiers + await user.click(container.querySelector('button[aria-expanded]')); expect(queryByRole('textbox')).toBeInTheDocument(); - expect(getByRole('textbox').value).toBe(STAGES_FIXTURE.name); - expect(getByRole('textbox').getAttribute('name')).toBe('stages.1.name'); + expect(getByRole('textbox').value).toBe('something'); + expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name'); + + expect(getByText(/blue/i)).toBeInTheDocument(); + expect( queryByRole('button', { name: /delete stage/i, diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js index 9a4060614b..3bb55d8459 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js @@ -45,11 +45,12 @@ function Stages({ stages }) { return ( 1} isOpen={!stage.id} + canReorder={stages.length > 1} + stagesCount={stages.length} /> ); 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 63ce038e77..c6dc9bb58f 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 @@ -4,13 +4,15 @@ import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; import { FormikProvider, useFormik } from 'formik'; import userEvent from '@testing-library/user-event'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; import { ThemeProvider, lightTheme } from '@strapi/design-system'; import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; import { Stages } from '../Stages'; import { reducer } from '../../../reducer'; -import { ACTION_SET_WORKFLOWS } from '../../../constants'; +import { ACTION_SET_WORKFLOWS, STAGE_COLOR_DEFAULT } from '../../../constants'; import * as actions from '../../../actions'; // without mocking actions as ESM it is impossible to spy on named exports @@ -27,11 +29,13 @@ jest.mock('@strapi/helper-plugin', () => ({ const STAGES_FIXTURE = [ { id: 1, + color: STAGE_COLOR_DEFAULT, name: 'stage-1', }, { id: 2, + color: STAGE_COLOR_DEFAULT, name: 'stage-2', }, ]; @@ -57,15 +61,17 @@ const ComponentFixture = (props) => { }); return ( - - - - - - - - - + + + + + + + + + + + ); }; 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 eac72aeb29..388ab4d8a1 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 @@ -1,6 +1,32 @@ +import { lightTheme } from '@strapi/design-system'; + export const REDUX_NAMESPACE = 'settings_review-workflows'; 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`; +export const ACTION_UPDATE_STAGE_POSITION = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE_POSITION`; + +export const STAGE_COLORS = { + primary600: 'Blue', + primary200: 'Lilac', + alternative600: 'Violet', + alternative200: 'Lavender', + success600: 'Green', + success200: 'Pale Green', + danger500: 'Cherry', + danger200: 'Pink', + warning600: 'Orange', + warning200: 'Yellow', + secondary600: 'Teal', + secondary200: 'Baby Blue', + neutral400: 'Gray', + neutral0: 'White', +}; + +export const STAGE_COLOR_DEFAULT = lightTheme.colors.primary600; + +export const DRAG_DROP_TYPES = { + STAGE: 'stage', +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js index dedec82f3e..7a9d6db2bc 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js @@ -6,6 +6,8 @@ import { ACTION_DELETE_STAGE, ACTION_ADD_STAGE, ACTION_UPDATE_STAGE, + ACTION_UPDATE_STAGE_POSITION, + STAGE_COLOR_DEFAULT, } from '../constants'; export const initialState = { @@ -29,8 +31,18 @@ export function reducer(state = initialState, action) { draft.status = status; - if (workflows) { - const defaultWorkflow = workflows[0]; + if (workflows?.length > 0) { + let defaultWorkflow = workflows[0]; + + // A safety net in case a stage does not have a color assigned; + // this normallly should not happen + defaultWorkflow = { + ...defaultWorkflow, + stages: defaultWorkflow.stages.map((stage) => ({ + ...stage, + color: stage?.color ?? STAGE_COLOR_DEFAULT, + })), + }; draft.serverState.workflows = workflows; draft.serverState.currentWorkflow = defaultWorkflow; @@ -69,6 +81,7 @@ export function reducer(state = initialState, action) { draft.clientState.currentWorkflow.data.stages.push({ ...payload, + color: payload?.color ?? STAGE_COLOR_DEFAULT, __temp_key__: newTempKey, }); @@ -91,6 +104,27 @@ export function reducer(state = initialState, action) { break; } + case ACTION_UPDATE_STAGE_POSITION: { + const { + currentWorkflow: { + data: { stages }, + }, + } = state.clientState; + const { newIndex, oldIndex } = payload; + + if (newIndex >= 0 && newIndex < stages.length) { + const stage = stages[oldIndex]; + let newStages = [...stages]; + + newStages.splice(oldIndex, 1); + newStages.splice(newIndex, 0, stage); + + draft.clientState.currentWorkflow.data.stages = newStages; + } + + break; + } + default: break; } 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 cef48abf9b..60563a58a9 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 @@ -5,6 +5,7 @@ import { ACTION_DELETE_STAGE, ACTION_ADD_STAGE, ACTION_UPDATE_STAGE, + ACTION_UPDATE_STAGE_POSITION, } from '../../constants'; const WORKFLOWS_FIXTURE = [ @@ -13,6 +14,7 @@ const WORKFLOWS_FIXTURE = [ stages: [ { id: 1, + color: 'red', name: 'stage-1', }, @@ -41,16 +43,26 @@ describe('Admin | Settings | Review Workflows | reducer', () => { payload: { status: 'loading-state', workflows: WORKFLOWS_FIXTURE }, }; + const DEFAULT_WORKFLOW_FIXTURE = { + ...WORKFLOWS_FIXTURE[0], + + // stages without a color should have a default color assigned + stages: WORKFLOWS_FIXTURE[0].stages.map((stage) => ({ + ...stage, + color: stage?.color ?? '#4945ff', + })), + }; + expect(reducer(state, action)).toStrictEqual( expect.objectContaining({ status: 'loading-state', serverState: expect.objectContaining({ - currentWorkflow: WORKFLOWS_FIXTURE[0], + currentWorkflow: DEFAULT_WORKFLOW_FIXTURE, workflows: WORKFLOWS_FIXTURE, }), clientState: expect.objectContaining({ currentWorkflow: expect.objectContaining({ - data: WORKFLOWS_FIXTURE[0], + data: DEFAULT_WORKFLOW_FIXTURE, isDirty: false, hasDeletedServerStages: false, }), @@ -225,6 +237,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { stages: expect.arrayContaining([ { __temp_key__: 3, + color: '#4945ff', name: 'something', }, ]), @@ -257,6 +270,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { stages: expect.arrayContaining([ { __temp_key__: 0, + color: expect.any(String), name: 'something', }, ]), @@ -306,6 +320,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { stages: expect.arrayContaining([ { __temp_key__: 4, + color: expect.any(String), name: 'something', }, ]), @@ -338,6 +353,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => { stages: expect.arrayContaining([ { id: 1, + color: 'red', name: 'stage-1-modified', }, ]), @@ -393,4 +409,112 @@ describe('Admin | Settings | Review Workflows | reducer', () => { }) ); }); + + test('ACTION_UPDATE_STAGE_POSITION', () => { + const action = { + type: ACTION_UPDATE_STAGE_POSITION, + payload: { oldIndex: 0, newIndex: 1 }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + isDirty: false, + }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: [ + expect.objectContaining({ name: 'stage-2' }), + expect.objectContaining({ name: 'stage-1' }), + ], + }), + isDirty: true, + }), + }), + }) + ); + }); + + test('ACTION_UPDATE_STAGE_POSITION - does not update position if new index is smaller than 0', () => { + const action = { + type: ACTION_UPDATE_STAGE_POSITION, + payload: { oldIndex: 0, newIndex: -1 }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + isDirty: false, + }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: [ + expect.objectContaining({ name: 'stage-1' }), + expect.objectContaining({ name: 'stage-2' }), + ], + }), + isDirty: false, + }), + }), + }) + ); + }); + + test('ACTION_UPDATE_STAGE_POSITION - does not update position if new index is greater than the amount of stages', () => { + const action = { + type: ACTION_UPDATE_STAGE_POSITION, + payload: { oldIndex: 0, newIndex: 3 }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + isDirty: false, + }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: [ + expect.objectContaining({ name: 'stage-1' }), + expect.objectContaining({ name: 'stage-2' }), + ], + }), + isDirty: false, + }), + }), + }) + ); + }); }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js index a576d61852..b279883011 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js @@ -8,6 +8,8 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { useNotification } from '@strapi/helper-plugin'; import { ThemeProvider, lightTheme } from '@strapi/design-system'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; import configureStore from '../../../../../../../admin/src/core/store/configureStore'; import ReviewWorkflowsPage from '..'; @@ -65,15 +67,17 @@ const ComponentFixture = () => { const store = configureStore([], [reducer]); return ( - - - - - - - - - + + + + + + + + + + + ); }; @@ -112,7 +116,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => { }); test('display stages', async () => { - const { getByText } = setup(); + const { getByText, queryByText } = setup(); + + await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument()); await waitFor(() => expect(getByText('1 stage')).toBeInTheDocument()); expect(getByText('stage-1')).toBeInTheDocument(); @@ -128,7 +134,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => { }); test('Save button is enabled after a stage has been added', async () => { - const { user, getByText, getByRole } = setup(); + const { user, getByText, getByRole, queryByText } = setup(); + + await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument()); await user.click( getByRole('button', { @@ -144,7 +152,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => { test('Successful Stage update', async () => { const toggleNotification = useNotification(); - const { user, getByRole } = setup(); + const { user, getByRole, queryByText } = setup(); + + await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument()); await user.click( getByRole('button', { @@ -169,7 +179,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => { test('Stage update with error', async () => { SHOULD_ERROR = true; const toggleNotification = useNotification(); - const { user, getByRole } = setup(); + const { user, getByRole, queryByText } = setup(); + + await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument()); await user.click( getByRole('button', { @@ -191,14 +203,18 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => { }); }); - test('Does not show a delete button if only stage is left', () => { - const { queryByRole } = setup(); + test('Does not show a delete button if only stage is left', async () => { + const { queryByRole, queryByText } = setup(); + + await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument()); expect(queryByRole('button', { name: /delete stage/i })).not.toBeInTheDocument(); }); test('Show confirmation dialog when a stage was deleted', async () => { - const { user, getByRole, getAllByRole } = setup(); + const { user, getByRole, getAllByRole, queryByText } = setup(); + + await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument()); await user.click( getByRole('button', { diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/colors.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/colors.js new file mode 100644 index 0000000000..0d10447f33 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/colors.js @@ -0,0 +1,33 @@ +import { lightTheme } from '@strapi/design-system'; + +import { STAGE_COLORS } from '../constants'; + +export function getStageColorByHex(hex) { + // there are multiple colors with the same hex code in the design tokens. In order to find + // the correct one we have to find all matching colors and then check, which ones are usable + // for stages. + const themeColors = Object.entries(lightTheme.colors).filter(([, value]) => value === hex); + const themeColorName = themeColors.reduce((acc, [name]) => { + if (STAGE_COLORS?.[name]) { + acc = name; + } + + return acc; + }, null); + + if (!themeColorName) { + return null; + } + + return { + themeColorName, + name: STAGE_COLORS[themeColorName], + }; +} + +export function getAvailableStageColors() { + return Object.entries(STAGE_COLORS).map(([themeColorName, name]) => ({ + hex: lightTheme.colors[themeColorName].toUpperCase(), + name, + })); +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/getWorkflowValidationSchema.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/getWorkflowValidationSchema.js index ed87817568..164a2eeb96 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/getWorkflowValidationSchema.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/getWorkflowValidationSchema.js @@ -19,6 +19,15 @@ export function getWorkflowValidationSchema({ formatMessage }) { defaultMessage: 'Name can not be longer than 255 characters', }) ), + color: yup + .string() + .required( + formatMessage({ + id: 'Settings.review-workflows.validation.stage.color', + defaultMessage: 'Color is required', + }) + ) + .matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), }) ), }); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/colors.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/colors.test.js new file mode 100644 index 0000000000..73b198c4d5 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/tests/colors.test.js @@ -0,0 +1,28 @@ +import { getAvailableStageColors, getStageColorByHex } from '../colors'; + +describe('Settings | Review Workflows | colors', () => { + test('getAvailableStageColors()', () => { + const colors = getAvailableStageColors(); + + expect(colors.length).toBe(14); + + colors.forEach((color) => { + expect(color).toMatchObject({ + hex: expect.any(String), + name: expect.any(String), + }); + + expect(color.hex).toBe(color.hex.toUpperCase()); + }); + }); + + test('getStageColorByHex()', () => { + expect(getStageColorByHex('#4945ff')).toStrictEqual({ + name: 'Blue', + themeColorName: 'primary600', + }); + + expect(getStageColorByHex('random')).toStrictEqual(null); + expect(getStageColorByHex()).toStrictEqual(null); + }); +}); diff --git a/packages/core/admin/ee/server/constants/workflows.js b/packages/core/admin/ee/server/constants/workflows.js index b9d04fba51..aa15028863 100644 --- a/packages/core/admin/ee/server/constants/workflows.js +++ b/packages/core/admin/ee/server/constants/workflows.js @@ -4,5 +4,6 @@ module.exports = { WORKFLOW_MODEL_UID: 'admin::workflow', STAGE_MODEL_UID: 'admin::workflow-stage', + STAGE_DEFAULT_COLOR: '#4945FF', ENTITY_STAGE_ATTRIBUTE: 'strapi_reviewWorkflows_stage', }; 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 48ab4118ad..4c24926c49 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 @@ -1,5 +1,7 @@ 'use strict'; +const { STAGE_DEFAULT_COLOR } = require('../../constants/workflows'); + module.exports = { schema: { collectionName: 'strapi_workflows_stages', @@ -24,6 +26,11 @@ module.exports = { type: 'string', configurable: false, }, + color: { + type: 'string', + configurable: false, + default: STAGE_DEFAULT_COLOR, + }, workflow: { type: 'relation', target: 'admin::workflow', diff --git a/packages/core/admin/ee/server/migrations/review-workflows-stages-color.js b/packages/core/admin/ee/server/migrations/review-workflows-stages-color.js new file mode 100644 index 0000000000..6f2395dc43 --- /dev/null +++ b/packages/core/admin/ee/server/migrations/review-workflows-stages-color.js @@ -0,0 +1,20 @@ +'use strict'; + +const { STAGE_DEFAULT_COLOR } = require('../constants/workflows'); + +async function migrateReviewWorkflowStagesColor({ oldContentTypes, contentTypes }) { + // Look for CT's color attribute + const hadColor = !!oldContentTypes?.['admin::workflow-stage']?.attributes?.color; + const hasColor = !!contentTypes['admin::workflow-stage']?.attributes?.color; + + // Add the default stage color if color attribute was added + if (!hadColor || hasColor) { + await strapi.query('admin::workflow-stage').updateMany({ + data: { + color: STAGE_DEFAULT_COLOR, + }, + }); + } +} + +module.exports = migrateReviewWorkflowStagesColor; diff --git a/packages/core/admin/ee/server/register.js b/packages/core/admin/ee/server/register.js index 72726acbb1..46cce24c38 100644 --- a/packages/core/admin/ee/server/register.js +++ b/packages/core/admin/ee/server/register.js @@ -3,6 +3,7 @@ const { features } = require('@strapi/strapi/lib/utils/ee'); const executeCERegister = require('../../server/register'); const migrateAuditLogsTable = require('./migrations/audit-logs-table'); +const migrateReviewWorkflowStagesColor = require('./migrations/review-workflows-stages-color'); const createAuditLogsService = require('./services/audit-logs'); const reviewWorkflowsMiddlewares = require('./middlewares/review-workflows'); const { getService } = require('./utils'); @@ -17,6 +18,7 @@ module.exports = async ({ strapi }) => { await auditLogsService.register(); } if (features.isEnabled('review-workflows')) { + strapi.hook('strapi::content-types.afterSync').register(migrateReviewWorkflowStagesColor); const reviewWorkflowService = getService('review-workflows'); reviewWorkflowsMiddlewares.contentTypeMiddleware(strapi); 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 520e1fb6fd..235bb7edb6 100644 --- a/packages/core/admin/ee/server/services/review-workflows/stages.js +++ b/packages/core/admin/ee/server/services/review-workflows/stages.js @@ -208,7 +208,7 @@ function getDiffBetweenStages(sourceStages, comparisonStages) { if (!srcStage) { acc.created.push(stageToCompare); - } else if (srcStage.name !== stageToCompare.name) { + } else if (srcStage.name !== stageToCompare.name || srcStage.color !== stageToCompare.color) { acc.updated.push(stageToCompare); } return acc; diff --git a/packages/core/admin/ee/server/validation/review-workflows.js b/packages/core/admin/ee/server/validation/review-workflows.js index 4f67c8645f..79bda0ba74 100644 --- a/packages/core/admin/ee/server/validation/review-workflows.js +++ b/packages/core/admin/ee/server/validation/review-workflows.js @@ -5,9 +5,15 @@ const { yup, validateYupSchema } = require('@strapi/utils'); 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 }); -const validateUpdateStagesSchema = yup.array().of(stageObject).required(); +const validateUpdateStagesSchema = yup + .array() + .of(stageObject) + .required() + .max(200, 'You can not create more than 200 stages'); + const validateUpdateStageOnEntity = yup .object() .shape({ diff --git a/packages/core/helper-plugin/src/components/DynamicTable/TableHead/index.js b/packages/core/helper-plugin/src/components/DynamicTable/TableHead/index.js index bd0fba0ee5..2d405c3413 100644 --- a/packages/core/helper-plugin/src/components/DynamicTable/TableHead/index.js +++ b/packages/core/helper-plugin/src/components/DynamicTable/TableHead/index.js @@ -26,7 +26,6 @@ const TableHead = ({ const [{ query }, setQuery] = useQueryParams(); const sort = query?.sort || ''; const [sortBy, sortOrder] = sort.split(':'); - const isIndeterminate = !areAllEntriesSelected && entriesToDelete.length > 0; return ( @@ -45,54 +44,67 @@ const TableHead = ({ /> )} - {headers.map(({ name, metadatas: { sortable: isSortable, label } }) => { - const isSorted = sortBy === name; - const isUp = sortOrder === 'ASC'; + {headers.map( + ({ fieldSchema, name, metadatas: { sortable: isSortable, label, mainField } }) => { + let isSorted = sortBy === name; + const isUp = sortOrder === 'ASC'; - const sortLabel = formatMessage( - { id: 'components.TableHeader.sort', defaultMessage: 'Sort on {label}' }, - { label } - ); - - const handleClickSort = (shouldAllowClick = true) => { - if (isSortable && shouldAllowClick) { - const nextSortOrder = isSorted && sortOrder === 'ASC' ? 'DESC' : 'ASC'; - const nextSort = `${name}:${nextSortOrder}`; - - setQuery({ - sort: nextSort, - }); + // relations always have to be sorted by their main field instead of only the + // attribute name; sortBy e.g. looks like: &sortBy=attributeName[mainField]:ASC + if (fieldSchema?.type === 'relation' && mainField) { + isSorted = sortBy === `${name}[${mainField}]`; } - }; - return ( - } - noBorder - /> - ) + const sortLabel = formatMessage( + { id: 'components.TableHeader.sort', defaultMessage: 'Sort on {label}' }, + { label } + ); + + const handleClickSort = (shouldAllowClick = true) => { + if (isSortable && shouldAllowClick) { + let nextSort = name; + + // relations always have to be sorted by their main field instead of only the + // attribute name; nextSort e.g. looks like: &nextSort=attributeName[mainField]:ASC + if (fieldSchema?.type === 'relation' && mainField) { + nextSort = `${name}[${mainField}]`; + } + + setQuery({ + sort: `${nextSort}:${isSorted && sortOrder === 'ASC' ? 'DESC' : 'ASC'}`, + }); } - > - - handleClickSort(!isSorted)} - variant="sigma" - > - {label} - - - - ); - })} + }; + + return ( + } + noBorder + /> + ) + } + > + + handleClickSort(!isSorted)} + variant="sigma" + > + {label} + + + + ); + } + )} {withBulkActions && (