Merge pull request #16585 from strapi/feature/review-workflow-1

Enhancement: Review workflow colors and reordering
This commit is contained in:
Gustav Hansen 2023-05-02 16:27:14 +02:00 committed by GitHub
commit 93c85d856d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 934 additions and 195 deletions

View File

@ -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 () => { test("It shouldn't be available for public", async () => {
const stagesRes = await requests.public.put( const stagesRes = await requests.public.put(
`/admin/review-workflows/workflows/${testWorkflow.id}/stages`, `/admin/review-workflows/workflows/${testWorkflow.id}/stages`,
stagesUpdateData { body: { data: stagesUpdateData } }
); );
const workflowRes = await requests.public.get( const workflowRes = await requests.public.get(
`/admin/review-workflows/workflows/${testWorkflow.id}` `/admin/review-workflows/workflows/${testWorkflow.id}`
@ -353,6 +368,19 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
expect(workflowRes.body.data).toBeUndefined(); 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', () => { 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', () => { describe('Review Workflow is enabled', () => {
beforeAll(async () => { beforeAll(async () => {
await updateContentType(productUID, { await updateContentType(productUID, {

View File

@ -1,15 +1,21 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; 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 ( return (
<Typography fontWeight="regular" textColor="neutral700"> <Flex alignItems="center" gap={2} maxWidth={pxToRem(300)}>
{name} <Box height={2} background={color} hasRadius shrink={0} width={2} />
</Typography>
<Typography fontWeight="regular" textColor="neutral700" ellipsis>
{name}
</Typography>
</Flex>
); );
} }
ReviewWorkflowsStageEE.propTypes = { ReviewWorkflowsStageEE.propTypes = {
color: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
}; };

View File

@ -4,6 +4,7 @@ import { Typography } from '@strapi/design-system';
import ReviewWorkflowsStage from '.'; import ReviewWorkflowsStage from '.';
import getTrad from '../../../../../../../admin/src/content-manager/utils/getTrad'; import getTrad from '../../../../../../../admin/src/content-manager/utils/getTrad';
import { STAGE_COLOR_DEFAULT } from '../../../../../pages/SettingsPage/pages/ReviewWorkflows/constants';
export default (layout) => { export default (layout) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@ -24,7 +25,7 @@ export default (layout) => {
key: '__strapi_reviewWorkflows_stage_temp_key__', key: '__strapi_reviewWorkflows_stage_temp_key__',
name: 'strapi_reviewWorkflows_stage', name: 'strapi_reviewWorkflows_stage',
fieldSchema: { fieldSchema: {
type: 'custom', type: 'relation',
}, },
metadatas: { metadatas: {
label: formatMessage({ label: formatMessage({
@ -32,7 +33,8 @@ export default (layout) => {
defaultMessage: 'Review stage', defaultMessage: 'Review stage',
}), }),
searchable: false, searchable: false,
sortable: false, sortable: true,
mainField: 'name',
}, },
cellFormatter({ strapi_reviewWorkflows_stage }) { cellFormatter({ strapi_reviewWorkflows_stage }) {
// if entities are created e.g. through lifecycle methods // if entities are created e.g. through lifecycle methods
@ -41,7 +43,9 @@ export default (layout) => {
return <Typography textColor="neutral800">-</Typography>; return <Typography textColor="neutral800">-</Typography>;
} }
return <ReviewWorkflowsStage name={strapi_reviewWorkflows_stage.name} />; const { color, name } = strapi_reviewWorkflows_stage;
return <ReviewWorkflowsStage color={color ?? STAGE_COLOR_DEFAULT} name={name} />;
}, },
}; };
}; };

View File

@ -17,7 +17,7 @@ const setup = (props) => render(<ComponentFixture {...props} />);
describe('DynamicTable | ReviewWorkflowsStage', () => { describe('DynamicTable | ReviewWorkflowsStage', () => {
test('render stage name', () => { test('render stage name', () => {
const { getByText } = setup({ name: 'reviewed' }); const { getByText } = setup({ color: 'red', name: 'reviewed' });
expect(getByText('reviewed')).toBeInTheDocument(); expect(getByText('reviewed')).toBeInTheDocument();
}); });

View File

@ -11,6 +11,8 @@ import { useIntl } from 'react-intl';
import { useMutation } from 'react-query'; import { useMutation } from 'react-query';
import { useReviewWorkflows } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows'; 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'; import Information from '../../../../../../admin/src/content-manager/pages/EditView/Information';
const ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage'; const ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage';
@ -113,6 +115,8 @@ export function InformationBoxEE() {
<ReactSelect <ReactSelect
components={{ components={{
LoadingIndicator: () => <Loader small />, LoadingIndicator: () => <Loader small />,
Option: OptionColor,
SingleValue: SingleValueColor,
}} }}
error={formattedError} error={formattedError}
inputId={ATTRIBUTE_NAME} inputId={ATTRIBUTE_NAME}
@ -122,9 +126,19 @@ export function InformationBoxEE() {
name={ATTRIBUTE_NAME} name={ATTRIBUTE_NAME}
onChange={handleStageChange} onChange={handleStageChange}
options={ 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,
}}
/> />
<FieldError /> <FieldError />

View File

@ -12,6 +12,7 @@ import { InformationBoxEE } from '../InformationBoxEE';
const STAGE_ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage'; const STAGE_ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage';
const STAGE_FIXTURE = { const STAGE_FIXTURE = {
id: 1, id: 1,
color: 'red',
name: 'Stage 1', name: 'Stage 1',
worklow: 1, worklow: 1,
}; };

View File

@ -18,12 +18,24 @@ import { Check } from '@strapi/icons';
import { Stages } from './components/Stages'; import { Stages } from './components/Stages';
import { reducer, initialState } from './reducer'; 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 { useInjectReducer } from '../../../../../../admin/src/hooks/useInjectReducer';
import { useReviewWorkflows } from './hooks/useReviewWorkflows'; import { useReviewWorkflows } from './hooks/useReviewWorkflows';
import { setWorkflows } from './actions'; import { setWorkflows } from './actions';
import { getWorkflowValidationSchema } from './utils/getWorkflowValidationSchema'; import { getWorkflowValidationSchema } from './utils/getWorkflowValidationSchema';
import adminPermissions from '../../../../../../admin/src/permissions'; 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 <StageDragPreview {...item} />;
default:
return null;
}
}
export function ReviewWorkflowsPage() { export function ReviewWorkflowsPage() {
const { trackUsage } = useTracking(); const { trackUsage } = useTracking();
@ -47,31 +59,15 @@ export function ReviewWorkflowsPage() {
const { mutateAsync, isLoading } = useMutation( const { mutateAsync, isLoading } = useMutation(
async ({ workflowId, stages }) => { async ({ workflowId, stages }) => {
try { const {
const { data: { data },
data: { data }, } = await put(`/admin/review-workflows/workflows/${workflowId}/stages`, {
} = await put(`/admin/review-workflows/workflows/${workflowId}/stages`, { data: stages,
data: stages, });
});
return data; return data;
} catch (error) {
toggleNotification({
type: 'warning',
message: formatAPIError(error),
});
}
return null;
}, },
{ {
onError(error) {
toggleNotification({
type: 'warning',
message: formatAPIError(error),
});
},
onSuccess() { onSuccess() {
toggleNotification({ toggleNotification({
type: 'success', type: 'success',
@ -81,8 +77,19 @@ export function ReviewWorkflowsPage() {
} }
); );
const updateWorkflowStages = (workflowId, stages) => { const updateWorkflowStages = async (workflowId, stages) => {
return mutateAsync({ workflowId, stages }); try {
const res = await mutateAsync({ workflowId, stages });
return res;
} catch (error) {
toggleNotification({
type: 'warning',
message: formatAPIError(error),
});
return null;
}
}; };
const submitForm = async () => { const submitForm = async () => {
@ -135,6 +142,8 @@ export function ReviewWorkflowsPage() {
})} })}
/> />
<Main tabIndex={-1}> <Main tabIndex={-1}>
<DragLayer renderItem={renderDragLayerItem} />
<FormikProvider value={formik}> <FormikProvider value={formik}>
<Form onSubmit={formik.handleSubmit}> <Form onSubmit={formik.handleSubmit}>
<HeaderLayout <HeaderLayout

View File

@ -3,6 +3,7 @@ import {
ACTION_DELETE_STAGE, ACTION_DELETE_STAGE,
ACTION_ADD_STAGE, ACTION_ADD_STAGE,
ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE,
ACTION_UPDATE_STAGE_POSITION,
} from '../constants'; } from '../constants';
export function setWorkflows({ status, data }) { export function setWorkflows({ status, data }) {
@ -40,3 +41,13 @@ export function updateStage(stageId, payload) {
}, },
}; };
} }
export function updateStagePosition(oldIndex, newIndex) {
return {
type: ACTION_UPDATE_STAGE_POSITION,
payload: {
newIndex,
oldIndex,
},
};
}

View File

@ -0,0 +1,45 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { Flex, Typography } from '@strapi/design-system';
import { CarretDown } from '@strapi/icons';
import { pxToRem } from '@strapi/helper-plugin';
const Toggle = styled(Flex)`
svg path {
fill: ${({ theme }) => theme.colors.neutral600};
}
`;
export function StageDragPreview({ name }) {
return (
<Flex
background="primary100"
borderStyle="dashed"
borderColor="primary600"
borderWidth="1px"
gap={3}
hasRadius
padding={3}
shadow="tableShadow"
width={pxToRem(300)}
>
<Toggle
alignItems="center"
background="neutral200"
borderRadius="50%"
height={6}
justifyContent="center"
width={6}
>
<CarretDown width={`${8 / 16}rem`} />
</Toggle>
<Typography fontWeight="bold">{name}</Typography>
</Flex>
);
}
StageDragPreview.propTypes = {
name: PropTypes.string.isRequired,
};

View File

@ -0,0 +1 @@
export * from './StageDragPreview';

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import * as React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useField } from 'formik'; import { useField } from 'formik';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@ -7,84 +7,308 @@ import {
Accordion, Accordion,
AccordionToggle, AccordionToggle,
AccordionContent, AccordionContent,
Box,
Field,
FieldLabel,
FieldError,
Flex,
Grid, Grid,
GridItem, GridItem,
IconButton, IconButton,
TextInput, TextInput,
VisuallyHidden,
} from '@strapi/design-system'; } from '@strapi/design-system';
import { useTracking } from '@strapi/helper-plugin'; import { ReactSelect, useTracking } from '@strapi/helper-plugin';
import { Trash } from '@strapi/icons'; 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 AVAILABLE_COLORS = getAvailableStageColors();
const { formatMessage } = useIntl();
const { trackUsage } = useTracking();
const [isOpen, setIsOpen] = useState(isOpenDefault);
const fieldIdentifier = `stages.${index}.name`;
const [field, meta] = useField(fieldIdentifier);
const dispatch = useDispatch();
function StageDropPreview() {
return ( return (
<Accordion <Box
size="S" background="primary100"
variant="primary" borderStyle="dashed"
onToggle={() => { borderColor="primary600"
setIsOpen(!isOpen); borderWidth="1px"
display="block"
if (!isOpen) { hasRadius
trackUsage('willEditStage'); padding={6}
}
}}
expanded={isOpen}
shadow="tableShadow" shadow="tableShadow"
> />
<AccordionToggle
title={name}
togglePosition="left"
action={
canDelete ? (
<IconButton
background="transparent"
noBorder
onClick={() => dispatch(deleteStage(id))}
label={formatMessage({
id: 'Settings.review-workflows.stage.delete',
defaultMessage: 'Delete stage',
})}
icon={<Trash />}
/>
) : null
}
/>
<AccordionContent padding={6} background="neutral0" hasRadius>
<Grid gap={4}>
<GridItem col={6}>
<TextInput
{...field}
id={fieldIdentifier}
value={name}
label={formatMessage({
id: 'Settings.review-workflows.stage.name.label',
defaultMessage: 'Stage name',
})}
error={meta.error ?? false}
onChange={(event) => {
field.onChange(event);
dispatch(updateStage(id, { name: event.target.value }));
}}
/>
</GridItem>
</Grid>
</AccordionContent>
</Accordion>
); );
} }
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 (
<Box ref={composedRef}>
{liveText && <VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>}
{isDragging ? (
<StageDropPreview />
) : (
<Accordion
size="S"
variant="primary"
onToggle={() => {
setIsOpen(!isOpen);
if (!isOpen) {
trackUsage('willEditStage');
}
}}
expanded={isOpen}
shadow="tableShadow"
>
<AccordionToggle
title={nameField.value}
togglePosition="left"
action={
<>
{canDelete && (
<IconButton
background="transparent"
icon={<Trash />}
label={formatMessage({
id: 'Settings.review-workflows.stage.delete',
defaultMessage: 'Delete stage',
})}
noBorder
onClick={() => dispatch(deleteStage(id))}
/>
)}
<IconButton
background="transparent"
forwardedAs="div"
role="button"
noBorder
tabIndex={0}
data-handler-id={handlerId}
ref={dragRef}
label={formatMessage({
id: 'Settings.review-workflows.stage.drag',
defaultMessage: 'Drag',
})}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<Drag />
</IconButton>
</>
}
/>
<AccordionContent padding={6} background="neutral0" hasRadius>
<Grid gap={4}>
<GridItem col={6}>
<TextInput
{...nameField}
id={nameField.name}
label={formatMessage({
id: 'Settings.review-workflows.stage.name.label',
defaultMessage: 'Stage name',
})}
error={nameMeta.error ?? false}
onChange={(event) => {
nameField.onChange(event);
dispatch(updateStage(id, { name: event.target.value }));
}}
required
/>
</GridItem>
<GridItem col={6}>
<Field
error={colorMeta?.error ?? false}
name={colorField.name}
id={colorField.name}
required
>
<Flex direction="column" gap={1} alignItems="stretch">
<FieldLabel>
{formatMessage({
id: 'content-manager.reviewWorkflows.stage.color',
defaultMessage: 'Color',
})}
</FieldLabel>
<ReactSelect
components={{ Option: OptionColor, SingleValue: SingleValueColor }}
error={colorMeta?.error}
inputId={colorField.name}
name={colorField.name}
options={colorOptions}
onChange={({ value }) => {
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,
}
}
/>
<FieldError />
</Flex>
</Field>
</GridItem>
</Grid>
</AccordionContent>
</Accordion>
)}
</Box>
);
}
Stage.propTypes = PropTypes.shape({ Stage.propTypes = PropTypes.shape({
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, color: PropTypes.string.isRequired,
canDelete: PropTypes.bool.isRequired, canDelete: PropTypes.bool.isRequired,
canReorder: PropTypes.bool.isRequired,
stagesCount: PropTypes.number.isRequired,
}).isRequired; }).isRequired;

View File

@ -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 (
<components.Option {...props}>
<Flex alignItems="center" gap={2}>
<Flex height={2} background={color} hasRadius shrink={0} width={2} />
<Typography textColor="neutral800" ellipsis>
{children}
</Typography>
</Flex>
</components.Option>
);
}
OptionColor.propTypes = {
children: PropTypes.node.isRequired,
data: PropTypes.shape({
color: PropTypes.string,
}).isRequired,
};

View File

@ -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 (
<components.SingleValue {...props}>
<Flex alignItems="center" gap={2}>
<Flex height={2} background={color} hasRadius shrink={0} width={2} />
<Typography textColor="neutral800" ellipsis>
{children}
</Typography>
</Flex>
</components.SingleValue>
);
}
SingleValueColor.defaultProps = {
children: null,
};
SingleValueColor.propTypes = {
children: PropTypes.node,
data: PropTypes.shape({
color: PropTypes.string,
}).isRequired,
};

View File

@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { FormikProvider, useFormik } from 'formik'; import { FormikProvider, useFormik } from 'formik';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
@ -11,6 +13,8 @@ import configureStore from '../../../../../../../../../../admin/src/core/store/c
import { Stage } from '../Stage'; import { Stage } from '../Stage';
import { reducer } from '../../../../reducer'; import { reducer } from '../../../../reducer';
import { STAGE_COLOR_DEFAULT } from '../../../../constants';
jest.mock('@strapi/helper-plugin', () => ({ jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'), ...jest.requireActual('@strapi/helper-plugin'),
useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }), useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }),
@ -18,8 +22,7 @@ jest.mock('@strapi/helper-plugin', () => ({
const STAGES_FIXTURE = { const STAGES_FIXTURE = {
id: 1, id: 1,
name: 'stage-1', index: 0,
index: 1,
}; };
const ComponentFixture = (props) => { const ComponentFixture = (props) => {
@ -30,6 +33,7 @@ const ComponentFixture = (props) => {
initialValues: { initialValues: {
stages: [ stages: [
{ {
color: STAGE_COLOR_DEFAULT,
name: 'something', name: 'something',
}, },
], ],
@ -38,15 +42,17 @@ const ComponentFixture = (props) => {
}); });
return ( return (
<Provider store={store}> <DndProvider backend={HTML5Backend}>
<FormikProvider value={formik}> <Provider store={store}>
<IntlProvider locale="en" messages={{}}> <FormikProvider value={formik}>
<ThemeProvider theme={lightTheme}> <IntlProvider locale="en" messages={{}}>
<Stage {...STAGES_FIXTURE} {...props} /> <ThemeProvider theme={lightTheme}>
</ThemeProvider> <Stage {...STAGES_FIXTURE} {...props} />
</IntlProvider> </ThemeProvider>
</FormikProvider> </IntlProvider>
</Provider> </FormikProvider>
</Provider>
</DndProvider>
); );
}; };
@ -60,15 +66,20 @@ describe('Admin | Settings | Review Workflow | Stage', () => {
}); });
it('should render a stage', async () => { it('should render a stage', async () => {
const { getByRole, queryByRole } = setup(); const { container, getByRole, getByText, queryByRole } = setup();
expect(queryByRole('textbox')).not.toBeInTheDocument(); 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(queryByRole('textbox')).toBeInTheDocument();
expect(getByRole('textbox').value).toBe(STAGES_FIXTURE.name); expect(getByRole('textbox').value).toBe('something');
expect(getByRole('textbox').getAttribute('name')).toBe('stages.1.name'); expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name');
expect(getByText(/blue/i)).toBeInTheDocument();
expect( expect(
queryByRole('button', { queryByRole('button', {
name: /delete stage/i, name: /delete stage/i,

View File

@ -45,11 +45,12 @@ function Stages({ stages }) {
return ( return (
<Box key={`stage-${id}`} as="li"> <Box key={`stage-${id}`} as="li">
<Stage <Stage
{...stage}
id={id} id={id}
index={index} index={index}
canDelete={stages.length > 1} canDelete={stages.length > 1}
isOpen={!stage.id} isOpen={!stage.id}
canReorder={stages.length > 1}
stagesCount={stages.length}
/> />
</Box> </Box>
); );

View File

@ -4,13 +4,15 @@ import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { FormikProvider, useFormik } from 'formik'; import { FormikProvider, useFormik } from 'formik';
import userEvent from '@testing-library/user-event'; 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 { ThemeProvider, lightTheme } from '@strapi/design-system';
import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; import configureStore from '../../../../../../../../../admin/src/core/store/configureStore';
import { Stages } from '../Stages'; import { Stages } from '../Stages';
import { reducer } from '../../../reducer'; import { reducer } from '../../../reducer';
import { ACTION_SET_WORKFLOWS } from '../../../constants'; import { ACTION_SET_WORKFLOWS, STAGE_COLOR_DEFAULT } from '../../../constants';
import * as actions from '../../../actions'; import * as actions from '../../../actions';
// without mocking actions as ESM it is impossible to spy on named exports // 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 = [ const STAGES_FIXTURE = [
{ {
id: 1, id: 1,
color: STAGE_COLOR_DEFAULT,
name: 'stage-1', name: 'stage-1',
}, },
{ {
id: 2, id: 2,
color: STAGE_COLOR_DEFAULT,
name: 'stage-2', name: 'stage-2',
}, },
]; ];
@ -57,15 +61,17 @@ const ComponentFixture = (props) => {
}); });
return ( return (
<Provider store={store}> <DndProvider backend={HTML5Backend}>
<FormikProvider value={formik}> <Provider store={store}>
<IntlProvider locale="en" messages={{}}> <FormikProvider value={formik}>
<ThemeProvider theme={lightTheme}> <IntlProvider locale="en" messages={{}}>
<Stages stages={STAGES_FIXTURE} {...props} /> <ThemeProvider theme={lightTheme}>
</ThemeProvider> <Stages stages={STAGES_FIXTURE} {...props} />
</IntlProvider> </ThemeProvider>
</FormikProvider> </IntlProvider>
</Provider> </FormikProvider>
</Provider>
</DndProvider>
); );
}; };

View File

@ -1,6 +1,32 @@
import { lightTheme } from '@strapi/design-system';
export const REDUX_NAMESPACE = 'settings_review-workflows'; export const REDUX_NAMESPACE = 'settings_review-workflows';
export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_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_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`;
export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_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 = `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',
};

View File

@ -6,6 +6,8 @@ import {
ACTION_DELETE_STAGE, ACTION_DELETE_STAGE,
ACTION_ADD_STAGE, ACTION_ADD_STAGE,
ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE,
ACTION_UPDATE_STAGE_POSITION,
STAGE_COLOR_DEFAULT,
} from '../constants'; } from '../constants';
export const initialState = { export const initialState = {
@ -29,8 +31,18 @@ export function reducer(state = initialState, action) {
draft.status = status; draft.status = status;
if (workflows) { if (workflows?.length > 0) {
const defaultWorkflow = workflows[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.workflows = workflows;
draft.serverState.currentWorkflow = defaultWorkflow; draft.serverState.currentWorkflow = defaultWorkflow;
@ -69,6 +81,7 @@ export function reducer(state = initialState, action) {
draft.clientState.currentWorkflow.data.stages.push({ draft.clientState.currentWorkflow.data.stages.push({
...payload, ...payload,
color: payload?.color ?? STAGE_COLOR_DEFAULT,
__temp_key__: newTempKey, __temp_key__: newTempKey,
}); });
@ -91,6 +104,27 @@ export function reducer(state = initialState, action) {
break; 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: default:
break; break;
} }

View File

@ -5,6 +5,7 @@ import {
ACTION_DELETE_STAGE, ACTION_DELETE_STAGE,
ACTION_ADD_STAGE, ACTION_ADD_STAGE,
ACTION_UPDATE_STAGE, ACTION_UPDATE_STAGE,
ACTION_UPDATE_STAGE_POSITION,
} from '../../constants'; } from '../../constants';
const WORKFLOWS_FIXTURE = [ const WORKFLOWS_FIXTURE = [
@ -13,6 +14,7 @@ const WORKFLOWS_FIXTURE = [
stages: [ stages: [
{ {
id: 1, id: 1,
color: 'red',
name: 'stage-1', name: 'stage-1',
}, },
@ -41,16 +43,26 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
payload: { status: 'loading-state', workflows: WORKFLOWS_FIXTURE }, 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(reducer(state, action)).toStrictEqual(
expect.objectContaining({ expect.objectContaining({
status: 'loading-state', status: 'loading-state',
serverState: expect.objectContaining({ serverState: expect.objectContaining({
currentWorkflow: WORKFLOWS_FIXTURE[0], currentWorkflow: DEFAULT_WORKFLOW_FIXTURE,
workflows: WORKFLOWS_FIXTURE, workflows: WORKFLOWS_FIXTURE,
}), }),
clientState: expect.objectContaining({ clientState: expect.objectContaining({
currentWorkflow: expect.objectContaining({ currentWorkflow: expect.objectContaining({
data: WORKFLOWS_FIXTURE[0], data: DEFAULT_WORKFLOW_FIXTURE,
isDirty: false, isDirty: false,
hasDeletedServerStages: false, hasDeletedServerStages: false,
}), }),
@ -225,6 +237,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
stages: expect.arrayContaining([ stages: expect.arrayContaining([
{ {
__temp_key__: 3, __temp_key__: 3,
color: '#4945ff',
name: 'something', name: 'something',
}, },
]), ]),
@ -257,6 +270,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
stages: expect.arrayContaining([ stages: expect.arrayContaining([
{ {
__temp_key__: 0, __temp_key__: 0,
color: expect.any(String),
name: 'something', name: 'something',
}, },
]), ]),
@ -306,6 +320,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
stages: expect.arrayContaining([ stages: expect.arrayContaining([
{ {
__temp_key__: 4, __temp_key__: 4,
color: expect.any(String),
name: 'something', name: 'something',
}, },
]), ]),
@ -338,6 +353,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
stages: expect.arrayContaining([ stages: expect.arrayContaining([
{ {
id: 1, id: 1,
color: 'red',
name: 'stage-1-modified', 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,
}),
}),
})
);
});
}); });

View File

@ -8,6 +8,8 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node';
import { useNotification } from '@strapi/helper-plugin'; import { useNotification } from '@strapi/helper-plugin';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; 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 configureStore from '../../../../../../../admin/src/core/store/configureStore';
import ReviewWorkflowsPage from '..'; import ReviewWorkflowsPage from '..';
@ -65,15 +67,17 @@ const ComponentFixture = () => {
const store = configureStore([], [reducer]); const store = configureStore([], [reducer]);
return ( return (
<QueryClientProvider client={client}> <DndProvider backend={HTML5Backend}>
<Provider store={store}> <QueryClientProvider client={client}>
<IntlProvider locale="en" messages={{}}> <Provider store={store}>
<ThemeProvider theme={lightTheme}> <IntlProvider locale="en" messages={{}}>
<ReviewWorkflowsPage /> <ThemeProvider theme={lightTheme}>
</ThemeProvider> <ReviewWorkflowsPage />
</IntlProvider> </ThemeProvider>
</Provider> </IntlProvider>
</QueryClientProvider> </Provider>
</QueryClientProvider>
</DndProvider>
); );
}; };
@ -112,7 +116,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
}); });
test('display stages', async () => { 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()); await waitFor(() => expect(getByText('1 stage')).toBeInTheDocument());
expect(getByText('stage-1')).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 () => { 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( await user.click(
getByRole('button', { getByRole('button', {
@ -144,7 +152,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
test('Successful Stage update', async () => { test('Successful Stage update', async () => {
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const { user, getByRole } = setup(); const { user, getByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
await user.click( await user.click(
getByRole('button', { getByRole('button', {
@ -169,7 +179,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
test('Stage update with error', async () => { test('Stage update with error', async () => {
SHOULD_ERROR = true; SHOULD_ERROR = true;
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const { user, getByRole } = setup(); const { user, getByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
await user.click( await user.click(
getByRole('button', { getByRole('button', {
@ -191,14 +203,18 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
}); });
}); });
test('Does not show a delete button if only stage is left', () => { test('Does not show a delete button if only stage is left', async () => {
const { queryByRole } = setup(); const { queryByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
expect(queryByRole('button', { name: /delete stage/i })).not.toBeInTheDocument(); expect(queryByRole('button', { name: /delete stage/i })).not.toBeInTheDocument();
}); });
test('Show confirmation dialog when a stage was deleted', async () => { 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( await user.click(
getByRole('button', { getByRole('button', {

View File

@ -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,
}));
}

View File

@ -19,6 +19,15 @@ export function getWorkflowValidationSchema({ formatMessage }) {
defaultMessage: 'Name can not be longer than 255 characters', 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),
}) })
), ),
}); });

View File

@ -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);
});
});

View File

@ -4,5 +4,6 @@
module.exports = { module.exports = {
WORKFLOW_MODEL_UID: 'admin::workflow', WORKFLOW_MODEL_UID: 'admin::workflow',
STAGE_MODEL_UID: 'admin::workflow-stage', STAGE_MODEL_UID: 'admin::workflow-stage',
STAGE_DEFAULT_COLOR: '#4945FF',
ENTITY_STAGE_ATTRIBUTE: 'strapi_reviewWorkflows_stage', ENTITY_STAGE_ATTRIBUTE: 'strapi_reviewWorkflows_stage',
}; };

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
const { STAGE_DEFAULT_COLOR } = require('../../constants/workflows');
module.exports = { module.exports = {
schema: { schema: {
collectionName: 'strapi_workflows_stages', collectionName: 'strapi_workflows_stages',
@ -24,6 +26,11 @@ module.exports = {
type: 'string', type: 'string',
configurable: false, configurable: false,
}, },
color: {
type: 'string',
configurable: false,
default: STAGE_DEFAULT_COLOR,
},
workflow: { workflow: {
type: 'relation', type: 'relation',
target: 'admin::workflow', target: 'admin::workflow',

View File

@ -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;

View File

@ -3,6 +3,7 @@
const { features } = require('@strapi/strapi/lib/utils/ee'); const { features } = require('@strapi/strapi/lib/utils/ee');
const executeCERegister = require('../../server/register'); const executeCERegister = require('../../server/register');
const migrateAuditLogsTable = require('./migrations/audit-logs-table'); const migrateAuditLogsTable = require('./migrations/audit-logs-table');
const migrateReviewWorkflowStagesColor = require('./migrations/review-workflows-stages-color');
const createAuditLogsService = require('./services/audit-logs'); const createAuditLogsService = require('./services/audit-logs');
const reviewWorkflowsMiddlewares = require('./middlewares/review-workflows'); const reviewWorkflowsMiddlewares = require('./middlewares/review-workflows');
const { getService } = require('./utils'); const { getService } = require('./utils');
@ -17,6 +18,7 @@ module.exports = async ({ strapi }) => {
await auditLogsService.register(); await auditLogsService.register();
} }
if (features.isEnabled('review-workflows')) { if (features.isEnabled('review-workflows')) {
strapi.hook('strapi::content-types.afterSync').register(migrateReviewWorkflowStagesColor);
const reviewWorkflowService = getService('review-workflows'); const reviewWorkflowService = getService('review-workflows');
reviewWorkflowsMiddlewares.contentTypeMiddleware(strapi); reviewWorkflowsMiddlewares.contentTypeMiddleware(strapi);

View File

@ -208,7 +208,7 @@ function getDiffBetweenStages(sourceStages, comparisonStages) {
if (!srcStage) { if (!srcStage) {
acc.created.push(stageToCompare); acc.created.push(stageToCompare);
} else if (srcStage.name !== stageToCompare.name) { } else if (srcStage.name !== stageToCompare.name || srcStage.color !== stageToCompare.color) {
acc.updated.push(stageToCompare); acc.updated.push(stageToCompare);
} }
return acc; return acc;

View File

@ -5,9 +5,15 @@ const { yup, validateYupSchema } = require('@strapi/utils');
const stageObject = yup.object().shape({ const stageObject = yup.object().shape({
id: yup.number().integer().min(1), id: yup.number().integer().min(1),
name: yup.string().max(255).required(), 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 const validateUpdateStageOnEntity = yup
.object() .object()
.shape({ .shape({

View File

@ -26,7 +26,6 @@ const TableHead = ({
const [{ query }, setQuery] = useQueryParams(); const [{ query }, setQuery] = useQueryParams();
const sort = query?.sort || ''; const sort = query?.sort || '';
const [sortBy, sortOrder] = sort.split(':'); const [sortBy, sortOrder] = sort.split(':');
const isIndeterminate = !areAllEntriesSelected && entriesToDelete.length > 0; const isIndeterminate = !areAllEntriesSelected && entriesToDelete.length > 0;
return ( return (
@ -45,54 +44,67 @@ const TableHead = ({
/> />
</Th> </Th>
)} )}
{headers.map(({ name, metadatas: { sortable: isSortable, label } }) => { {headers.map(
const isSorted = sortBy === name; ({ fieldSchema, name, metadatas: { sortable: isSortable, label, mainField } }) => {
const isUp = sortOrder === 'ASC'; let isSorted = sortBy === name;
const isUp = sortOrder === 'ASC';
const sortLabel = formatMessage( // relations always have to be sorted by their main field instead of only the
{ id: 'components.TableHeader.sort', defaultMessage: 'Sort on {label}' }, // attribute name; sortBy e.g. looks like: &sortBy=attributeName[mainField]:ASC
{ label } if (fieldSchema?.type === 'relation' && mainField) {
); isSorted = sortBy === `${name}[${mainField}]`;
const handleClickSort = (shouldAllowClick = true) => {
if (isSortable && shouldAllowClick) {
const nextSortOrder = isSorted && sortOrder === 'ASC' ? 'DESC' : 'ASC';
const nextSort = `${name}:${nextSortOrder}`;
setQuery({
sort: nextSort,
});
} }
};
return ( const sortLabel = formatMessage(
<Th { id: 'components.TableHeader.sort', defaultMessage: 'Sort on {label}' },
key={name} { label }
action={ );
isSorted && (
<IconButton const handleClickSort = (shouldAllowClick = true) => {
label={sortLabel} if (isSortable && shouldAllowClick) {
onClick={handleClickSort} let nextSort = name;
icon={isSorted && <SortIcon isUp={isUp} />}
noBorder // 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'}`,
});
} }
> };
<Tooltip label={isSortable ? sortLabel : label}>
<Typography return (
textColor="neutral600" <Th
as={!isSorted && isSortable ? 'button' : 'span'} key={name}
label={label} action={
onClick={() => handleClickSort(!isSorted)} isSorted && (
variant="sigma" <IconButton
> label={sortLabel}
{label} onClick={handleClickSort}
</Typography> icon={isSorted && <SortIcon isUp={isUp} />}
</Tooltip> noBorder
</Th> />
); )
})} }
>
<Tooltip label={isSortable ? sortLabel : label}>
<Typography
textColor="neutral600"
as={!isSorted && isSortable ? 'button' : 'span'}
label={label}
onClick={() => handleClickSort(!isSorted)}
variant="sigma"
>
{label}
</Typography>
</Tooltip>
</Th>
);
}
)}
{withBulkActions && ( {withBulkActions && (
<Th> <Th>