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 () => {
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, {

View File

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

View File

@ -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 <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', () => {
test('render stage name', () => {
const { getByText } = setup({ name: 'reviewed' });
const { getByText } = setup({ color: 'red', name: 'reviewed' });
expect(getByText('reviewed')).toBeInTheDocument();
});

View File

@ -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() {
<ReactSelect
components={{
LoadingIndicator: () => <Loader small />,
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,
}}
/>
<FieldError />

View File

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

View File

@ -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 <StageDragPreview {...item} />;
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() {
})}
/>
<Main tabIndex={-1}>
<DragLayer renderItem={renderDragLayerItem} />
<FormikProvider value={formik}>
<Form onSubmit={formik.handleSubmit}>
<HeaderLayout

View File

@ -3,6 +3,7 @@ import {
ACTION_DELETE_STAGE,
ACTION_ADD_STAGE,
ACTION_UPDATE_STAGE,
ACTION_UPDATE_STAGE_POSITION,
} from '../constants';
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 { 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 (
<Accordion
size="S"
variant="primary"
onToggle={() => {
setIsOpen(!isOpen);
if (!isOpen) {
trackUsage('willEditStage');
}
}}
expanded={isOpen}
<Box
background="primary100"
borderStyle="dashed"
borderColor="primary600"
borderWidth="1px"
display="block"
hasRadius
padding={6}
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({
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;

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 { 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 (
<Provider store={store}>
<FormikProvider value={formik}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<Stage {...STAGES_FIXTURE} {...props} />
</ThemeProvider>
</IntlProvider>
</FormikProvider>
</Provider>
<DndProvider backend={HTML5Backend}>
<Provider store={store}>
<FormikProvider value={formik}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<Stage {...STAGES_FIXTURE} {...props} />
</ThemeProvider>
</IntlProvider>
</FormikProvider>
</Provider>
</DndProvider>
);
};
@ -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,

View File

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

View File

@ -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 (
<Provider store={store}>
<FormikProvider value={formik}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<Stages stages={STAGES_FIXTURE} {...props} />
</ThemeProvider>
</IntlProvider>
</FormikProvider>
</Provider>
<DndProvider backend={HTML5Backend}>
<Provider store={store}>
<FormikProvider value={formik}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<Stages stages={STAGES_FIXTURE} {...props} />
</ThemeProvider>
</IntlProvider>
</FormikProvider>
</Provider>
</DndProvider>
);
};

View File

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

View File

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

View File

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

View File

@ -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 (
<QueryClientProvider client={client}>
<Provider store={store}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<ReviewWorkflowsPage />
</ThemeProvider>
</IntlProvider>
</Provider>
</QueryClientProvider>
<DndProvider backend={HTML5Backend}>
<QueryClientProvider client={client}>
<Provider store={store}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<ReviewWorkflowsPage />
</ThemeProvider>
</IntlProvider>
</Provider>
</QueryClientProvider>
</DndProvider>
);
};
@ -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', {

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',
})
),
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 = {
WORKFLOW_MODEL_UID: 'admin::workflow',
STAGE_MODEL_UID: 'admin::workflow-stage',
STAGE_DEFAULT_COLOR: '#4945FF',
ENTITY_STAGE_ATTRIBUTE: 'strapi_reviewWorkflows_stage',
};

View File

@ -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',

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

View File

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

View File

@ -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({

View File

@ -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 = ({
/>
</Th>
)}
{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 (
<Th
key={name}
action={
isSorted && (
<IconButton
label={sortLabel}
onClick={handleClickSort}
icon={isSorted && <SortIcon isUp={isUp} />}
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'}`,
});
}
>
<Tooltip label={isSortable ? sortLabel : label}>
<Typography
textColor="neutral600"
as={!isSorted && isSortable ? 'button' : 'span'}
label={label}
onClick={() => handleClickSort(!isSorted)}
variant="sigma"
>
{label}
</Typography>
</Tooltip>
</Th>
);
})}
};
return (
<Th
key={name}
action={
isSorted && (
<IconButton
label={sortLabel}
onClick={handleClickSort}
icon={isSorted && <SortIcon isUp={isUp} />}
noBorder
/>
)
}
>
<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 && (
<Th>