mirror of
https://github.com/strapi/strapi.git
synced 2025-09-25 08:19:07 +00:00
Merge pull request #16585 from strapi/feature/review-workflow-1
Enhancement: Review workflow colors and reordering
This commit is contained in:
commit
93c85d856d
@ -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, {
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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} />;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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 />
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './StageDragPreview';
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './OptionColor';
|
@ -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,
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './SingleValueColor';
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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', {
|
||||
|
@ -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,
|
||||
}));
|
||||
}
|
@ -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),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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',
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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;
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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({
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user