mirror of
https://github.com/strapi/strapi.git
synced 2025-09-25 16:29:34 +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 () => {
|
test("It shouldn't be available for public", async () => {
|
||||||
const stagesRes = await requests.public.put(
|
const stagesRes = await requests.public.put(
|
||||||
`/admin/review-workflows/workflows/${testWorkflow.id}/stages`,
|
`/admin/review-workflows/workflows/${testWorkflow.id}/stages`,
|
||||||
stagesUpdateData
|
{ body: { data: stagesUpdateData } }
|
||||||
);
|
);
|
||||||
const workflowRes = await requests.public.get(
|
const workflowRes = await requests.public.get(
|
||||||
`/admin/review-workflows/workflows/${testWorkflow.id}`
|
`/admin/review-workflows/workflows/${testWorkflow.id}`
|
||||||
@ -353,6 +368,19 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
|
|||||||
expect(workflowRes.body.data).toBeUndefined();
|
expect(workflowRes.body.data).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test('It should throw an error if trying to create more than 200 stages', async () => {
|
||||||
|
const stagesRes = await requests.admin.put(
|
||||||
|
`/admin/review-workflows/workflows/${testWorkflow.id}/stages`,
|
||||||
|
{ body: { data: Array(201).fill({ name: 'new stage' }) } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasRW) {
|
||||||
|
expect(stagesRes.status).toBe(400);
|
||||||
|
expect(stagesRes.body.error).toBeDefined();
|
||||||
|
expect(stagesRes.body.error.name).toEqual('ValidationError');
|
||||||
|
expect(stagesRes.body.error.message).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Enabling/Disabling review workflows on a content type', () => {
|
describe('Enabling/Disabling review workflows on a content type', () => {
|
||||||
@ -416,7 +444,7 @@ describeOnCondition(edition === 'EE')('Review workflows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update a stage on an entity', () => {
|
describe('Update a stage on an entity', () => {
|
||||||
describe('Review Workflow is enabled', () => {
|
describe('Review Workflow is enabled', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await updateContentType(productUID, {
|
await updateContentType(productUID, {
|
||||||
|
@ -1,15 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Typography } from '@strapi/design-system';
|
import { Box, Flex, Typography } from '@strapi/design-system';
|
||||||
|
import { pxToRem } from '@strapi/helper-plugin';
|
||||||
|
|
||||||
export function ReviewWorkflowsStageEE({ name }) {
|
export function ReviewWorkflowsStageEE({ color, name }) {
|
||||||
return (
|
return (
|
||||||
<Typography fontWeight="regular" textColor="neutral700">
|
<Flex alignItems="center" gap={2} maxWidth={pxToRem(300)}>
|
||||||
{name}
|
<Box height={2} background={color} hasRadius shrink={0} width={2} />
|
||||||
</Typography>
|
|
||||||
|
<Typography fontWeight="regular" textColor="neutral700" ellipsis>
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ReviewWorkflowsStageEE.propTypes = {
|
ReviewWorkflowsStageEE.propTypes = {
|
||||||
|
color: PropTypes.string.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ import { Typography } from '@strapi/design-system';
|
|||||||
|
|
||||||
import ReviewWorkflowsStage from '.';
|
import ReviewWorkflowsStage from '.';
|
||||||
import getTrad from '../../../../../../../admin/src/content-manager/utils/getTrad';
|
import getTrad from '../../../../../../../admin/src/content-manager/utils/getTrad';
|
||||||
|
import { STAGE_COLOR_DEFAULT } from '../../../../../pages/SettingsPage/pages/ReviewWorkflows/constants';
|
||||||
|
|
||||||
export default (layout) => {
|
export default (layout) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
@ -24,7 +25,7 @@ export default (layout) => {
|
|||||||
key: '__strapi_reviewWorkflows_stage_temp_key__',
|
key: '__strapi_reviewWorkflows_stage_temp_key__',
|
||||||
name: 'strapi_reviewWorkflows_stage',
|
name: 'strapi_reviewWorkflows_stage',
|
||||||
fieldSchema: {
|
fieldSchema: {
|
||||||
type: 'custom',
|
type: 'relation',
|
||||||
},
|
},
|
||||||
metadatas: {
|
metadatas: {
|
||||||
label: formatMessage({
|
label: formatMessage({
|
||||||
@ -32,7 +33,8 @@ export default (layout) => {
|
|||||||
defaultMessage: 'Review stage',
|
defaultMessage: 'Review stage',
|
||||||
}),
|
}),
|
||||||
searchable: false,
|
searchable: false,
|
||||||
sortable: false,
|
sortable: true,
|
||||||
|
mainField: 'name',
|
||||||
},
|
},
|
||||||
cellFormatter({ strapi_reviewWorkflows_stage }) {
|
cellFormatter({ strapi_reviewWorkflows_stage }) {
|
||||||
// if entities are created e.g. through lifecycle methods
|
// if entities are created e.g. through lifecycle methods
|
||||||
@ -41,7 +43,9 @@ export default (layout) => {
|
|||||||
return <Typography textColor="neutral800">-</Typography>;
|
return <Typography textColor="neutral800">-</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ReviewWorkflowsStage name={strapi_reviewWorkflows_stage.name} />;
|
const { color, name } = strapi_reviewWorkflows_stage;
|
||||||
|
|
||||||
|
return <ReviewWorkflowsStage color={color ?? STAGE_COLOR_DEFAULT} name={name} />;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -17,7 +17,7 @@ const setup = (props) => render(<ComponentFixture {...props} />);
|
|||||||
|
|
||||||
describe('DynamicTable | ReviewWorkflowsStage', () => {
|
describe('DynamicTable | ReviewWorkflowsStage', () => {
|
||||||
test('render stage name', () => {
|
test('render stage name', () => {
|
||||||
const { getByText } = setup({ name: 'reviewed' });
|
const { getByText } = setup({ color: 'red', name: 'reviewed' });
|
||||||
|
|
||||||
expect(getByText('reviewed')).toBeInTheDocument();
|
expect(getByText('reviewed')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,8 @@ import { useIntl } from 'react-intl';
|
|||||||
import { useMutation } from 'react-query';
|
import { useMutation } from 'react-query';
|
||||||
|
|
||||||
import { useReviewWorkflows } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows';
|
import { useReviewWorkflows } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows';
|
||||||
|
import { OptionColor } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/OptionColor';
|
||||||
|
import { SingleValueColor } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/components/SingleValueColor';
|
||||||
import Information from '../../../../../../admin/src/content-manager/pages/EditView/Information';
|
import Information from '../../../../../../admin/src/content-manager/pages/EditView/Information';
|
||||||
|
|
||||||
const ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage';
|
const ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage';
|
||||||
@ -113,6 +115,8 @@ export function InformationBoxEE() {
|
|||||||
<ReactSelect
|
<ReactSelect
|
||||||
components={{
|
components={{
|
||||||
LoadingIndicator: () => <Loader small />,
|
LoadingIndicator: () => <Loader small />,
|
||||||
|
Option: OptionColor,
|
||||||
|
SingleValue: SingleValueColor,
|
||||||
}}
|
}}
|
||||||
error={formattedError}
|
error={formattedError}
|
||||||
inputId={ATTRIBUTE_NAME}
|
inputId={ATTRIBUTE_NAME}
|
||||||
@ -122,9 +126,19 @@ export function InformationBoxEE() {
|
|||||||
name={ATTRIBUTE_NAME}
|
name={ATTRIBUTE_NAME}
|
||||||
onChange={handleStageChange}
|
onChange={handleStageChange}
|
||||||
options={
|
options={
|
||||||
workflow ? workflow.stages.map(({ id, name }) => ({ value: id, label: name })) : []
|
workflow
|
||||||
|
? workflow.stages.map(({ id, color, name }) => ({
|
||||||
|
value: id,
|
||||||
|
label: name,
|
||||||
|
color,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
value={{ value: activeWorkflowStage?.id, label: activeWorkflowStage?.name }}
|
value={{
|
||||||
|
value: activeWorkflowStage?.id,
|
||||||
|
label: activeWorkflowStage?.name,
|
||||||
|
color: activeWorkflowStage?.color,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FieldError />
|
<FieldError />
|
||||||
|
@ -12,6 +12,7 @@ import { InformationBoxEE } from '../InformationBoxEE';
|
|||||||
const STAGE_ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage';
|
const STAGE_ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage';
|
||||||
const STAGE_FIXTURE = {
|
const STAGE_FIXTURE = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
color: 'red',
|
||||||
name: 'Stage 1',
|
name: 'Stage 1',
|
||||||
worklow: 1,
|
worklow: 1,
|
||||||
};
|
};
|
||||||
|
@ -18,12 +18,24 @@ import { Check } from '@strapi/icons';
|
|||||||
|
|
||||||
import { Stages } from './components/Stages';
|
import { Stages } from './components/Stages';
|
||||||
import { reducer, initialState } from './reducer';
|
import { reducer, initialState } from './reducer';
|
||||||
import { REDUX_NAMESPACE } from './constants';
|
import { REDUX_NAMESPACE, DRAG_DROP_TYPES } from './constants';
|
||||||
import { useInjectReducer } from '../../../../../../admin/src/hooks/useInjectReducer';
|
import { useInjectReducer } from '../../../../../../admin/src/hooks/useInjectReducer';
|
||||||
import { useReviewWorkflows } from './hooks/useReviewWorkflows';
|
import { useReviewWorkflows } from './hooks/useReviewWorkflows';
|
||||||
import { setWorkflows } from './actions';
|
import { setWorkflows } from './actions';
|
||||||
import { getWorkflowValidationSchema } from './utils/getWorkflowValidationSchema';
|
import { getWorkflowValidationSchema } from './utils/getWorkflowValidationSchema';
|
||||||
import adminPermissions from '../../../../../../admin/src/permissions';
|
import adminPermissions from '../../../../../../admin/src/permissions';
|
||||||
|
import { StageDragPreview } from './components/StageDragPreview';
|
||||||
|
import { DragLayer } from '../../../../../../admin/src/components/DragLayer';
|
||||||
|
|
||||||
|
function renderDragLayerItem({ type, item }) {
|
||||||
|
switch (type) {
|
||||||
|
case DRAG_DROP_TYPES.STAGE:
|
||||||
|
return <StageDragPreview {...item} />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ReviewWorkflowsPage() {
|
export function ReviewWorkflowsPage() {
|
||||||
const { trackUsage } = useTracking();
|
const { trackUsage } = useTracking();
|
||||||
@ -47,31 +59,15 @@ export function ReviewWorkflowsPage() {
|
|||||||
|
|
||||||
const { mutateAsync, isLoading } = useMutation(
|
const { mutateAsync, isLoading } = useMutation(
|
||||||
async ({ workflowId, stages }) => {
|
async ({ workflowId, stages }) => {
|
||||||
try {
|
const {
|
||||||
const {
|
data: { data },
|
||||||
data: { data },
|
} = await put(`/admin/review-workflows/workflows/${workflowId}/stages`, {
|
||||||
} = await put(`/admin/review-workflows/workflows/${workflowId}/stages`, {
|
data: stages,
|
||||||
data: stages,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
|
||||||
toggleNotification({
|
|
||||||
type: 'warning',
|
|
||||||
message: formatAPIError(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError(error) {
|
|
||||||
toggleNotification({
|
|
||||||
type: 'warning',
|
|
||||||
message: formatAPIError(error),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
toggleNotification({
|
toggleNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@ -81,8 +77,19 @@ export function ReviewWorkflowsPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateWorkflowStages = (workflowId, stages) => {
|
const updateWorkflowStages = async (workflowId, stages) => {
|
||||||
return mutateAsync({ workflowId, stages });
|
try {
|
||||||
|
const res = await mutateAsync({ workflowId, stages });
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
toggleNotification({
|
||||||
|
type: 'warning',
|
||||||
|
message: formatAPIError(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
@ -135,6 +142,8 @@ export function ReviewWorkflowsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<Main tabIndex={-1}>
|
<Main tabIndex={-1}>
|
||||||
|
<DragLayer renderItem={renderDragLayerItem} />
|
||||||
|
|
||||||
<FormikProvider value={formik}>
|
<FormikProvider value={formik}>
|
||||||
<Form onSubmit={formik.handleSubmit}>
|
<Form onSubmit={formik.handleSubmit}>
|
||||||
<HeaderLayout
|
<HeaderLayout
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
ACTION_DELETE_STAGE,
|
ACTION_DELETE_STAGE,
|
||||||
ACTION_ADD_STAGE,
|
ACTION_ADD_STAGE,
|
||||||
ACTION_UPDATE_STAGE,
|
ACTION_UPDATE_STAGE,
|
||||||
|
ACTION_UPDATE_STAGE_POSITION,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
export function setWorkflows({ status, data }) {
|
export function setWorkflows({ status, data }) {
|
||||||
@ -40,3 +41,13 @@ export function updateStage(stageId, payload) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateStagePosition(oldIndex, newIndex) {
|
||||||
|
return {
|
||||||
|
type: ACTION_UPDATE_STAGE_POSITION,
|
||||||
|
payload: {
|
||||||
|
newIndex,
|
||||||
|
oldIndex,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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 PropTypes from 'prop-types';
|
||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@ -7,84 +7,308 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
AccordionToggle,
|
AccordionToggle,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
|
Box,
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldError,
|
||||||
|
Flex,
|
||||||
Grid,
|
Grid,
|
||||||
GridItem,
|
GridItem,
|
||||||
IconButton,
|
IconButton,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
VisuallyHidden,
|
||||||
} from '@strapi/design-system';
|
} from '@strapi/design-system';
|
||||||
import { useTracking } from '@strapi/helper-plugin';
|
import { ReactSelect, useTracking } from '@strapi/helper-plugin';
|
||||||
import { Trash } from '@strapi/icons';
|
import { Drag, Trash } from '@strapi/icons';
|
||||||
|
|
||||||
import { deleteStage, updateStage } from '../../../actions';
|
import { deleteStage, updateStagePosition, updateStage } from '../../../actions';
|
||||||
|
import { getAvailableStageColors } from '../../../utils/colors';
|
||||||
|
import { OptionColor } from './components/OptionColor';
|
||||||
|
import { SingleValueColor } from './components/SingleValueColor';
|
||||||
|
import { useDragAndDrop } from '../../../../../../../../../admin/src/content-manager/hooks';
|
||||||
|
import { composeRefs } from '../../../../../../../../../admin/src/content-manager/utils';
|
||||||
|
import { DRAG_DROP_TYPES } from '../../../constants';
|
||||||
|
|
||||||
function Stage({ id, name, index, canDelete, isOpen: isOpenDefault = false }) {
|
const AVAILABLE_COLORS = getAvailableStageColors();
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const { trackUsage } = useTracking();
|
|
||||||
const [isOpen, setIsOpen] = useState(isOpenDefault);
|
|
||||||
const fieldIdentifier = `stages.${index}.name`;
|
|
||||||
const [field, meta] = useField(fieldIdentifier);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
|
function StageDropPreview() {
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Box
|
||||||
size="S"
|
background="primary100"
|
||||||
variant="primary"
|
borderStyle="dashed"
|
||||||
onToggle={() => {
|
borderColor="primary600"
|
||||||
setIsOpen(!isOpen);
|
borderWidth="1px"
|
||||||
|
display="block"
|
||||||
if (!isOpen) {
|
hasRadius
|
||||||
trackUsage('willEditStage');
|
padding={6}
|
||||||
}
|
|
||||||
}}
|
|
||||||
expanded={isOpen}
|
|
||||||
shadow="tableShadow"
|
shadow="tableShadow"
|
||||||
>
|
/>
|
||||||
<AccordionToggle
|
|
||||||
title={name}
|
|
||||||
togglePosition="left"
|
|
||||||
action={
|
|
||||||
canDelete ? (
|
|
||||||
<IconButton
|
|
||||||
background="transparent"
|
|
||||||
noBorder
|
|
||||||
onClick={() => dispatch(deleteStage(id))}
|
|
||||||
label={formatMessage({
|
|
||||||
id: 'Settings.review-workflows.stage.delete',
|
|
||||||
defaultMessage: 'Delete stage',
|
|
||||||
})}
|
|
||||||
icon={<Trash />}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AccordionContent padding={6} background="neutral0" hasRadius>
|
|
||||||
<Grid gap={4}>
|
|
||||||
<GridItem col={6}>
|
|
||||||
<TextInput
|
|
||||||
{...field}
|
|
||||||
id={fieldIdentifier}
|
|
||||||
value={name}
|
|
||||||
label={formatMessage({
|
|
||||||
id: 'Settings.review-workflows.stage.name.label',
|
|
||||||
defaultMessage: 'Stage name',
|
|
||||||
})}
|
|
||||||
error={meta.error ?? false}
|
|
||||||
onChange={(event) => {
|
|
||||||
field.onChange(event);
|
|
||||||
dispatch(updateStage(id, { name: event.target.value }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</GridItem>
|
|
||||||
</Grid>
|
|
||||||
</AccordionContent>
|
|
||||||
</Accordion>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Stage };
|
export function Stage({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
canDelete,
|
||||||
|
canReorder,
|
||||||
|
isOpen: isOpenDefault = false,
|
||||||
|
stagesCount,
|
||||||
|
}) {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const getItemPos = (index) => `${index + 1} of ${stagesCount}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleGrabStage = (index) => {
|
||||||
|
setLiveText(
|
||||||
|
formatMessage(
|
||||||
|
{
|
||||||
|
id: 'dnd.grab-item',
|
||||||
|
defaultMessage: `{item}, grabbed. Current position in list: {position}. Press up and down arrow to change position, Spacebar to drop, Escape to cancel.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: nameField.value,
|
||||||
|
position: getItemPos(index),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleDropStage = (index) => {
|
||||||
|
setLiveText(
|
||||||
|
formatMessage(
|
||||||
|
{
|
||||||
|
id: 'dnd.drop-item',
|
||||||
|
defaultMessage: `{item}, dropped. Final position in list: {position}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: nameField.value,
|
||||||
|
position: getItemPos(index),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleCancelDragStage = () => {
|
||||||
|
setLiveText(
|
||||||
|
formatMessage(
|
||||||
|
{
|
||||||
|
id: 'dnd.cancel-item',
|
||||||
|
defaultMessage: '{item}, dropped. Re-order cancelled.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: nameField.value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveStage = (newIndex, oldIndex) => {
|
||||||
|
setLiveText(
|
||||||
|
formatMessage(
|
||||||
|
{
|
||||||
|
id: 'dnd.reorder',
|
||||||
|
defaultMessage: '{item}, moved. New position in list: {position}.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: nameField.value,
|
||||||
|
position: getItemPos(newIndex),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(updateStagePosition(oldIndex, newIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [liveText, setLiveText] = React.useState(null);
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { trackUsage } = useTracking();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [isOpen, setIsOpen] = React.useState(isOpenDefault);
|
||||||
|
const [nameField, nameMeta] = useField(`stages.${index}.name`);
|
||||||
|
const [colorField, colorMeta] = useField(`stages.${index}.color`);
|
||||||
|
const [{ handlerId, isDragging, handleKeyDown }, stageRef, dropRef, dragRef] = useDragAndDrop(
|
||||||
|
canReorder,
|
||||||
|
{
|
||||||
|
index,
|
||||||
|
item: {
|
||||||
|
name: nameField.value,
|
||||||
|
},
|
||||||
|
onGrabItem: handleGrabStage,
|
||||||
|
onDropItem: handleDropStage,
|
||||||
|
onMoveItem: handleMoveStage,
|
||||||
|
onCancel: handleCancelDragStage,
|
||||||
|
type: DRAG_DROP_TYPES.STAGE,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const composedRef = composeRefs(stageRef, dropRef);
|
||||||
|
|
||||||
|
const colorOptions = AVAILABLE_COLORS.map(({ hex, name }) => ({
|
||||||
|
value: hex,
|
||||||
|
label: formatMessage(
|
||||||
|
{
|
||||||
|
id: 'Settings.review-workflows.stage.color.name',
|
||||||
|
defaultMessage: '{name}',
|
||||||
|
},
|
||||||
|
{ name }
|
||||||
|
),
|
||||||
|
color: hex,
|
||||||
|
}));
|
||||||
|
// TODO: the .toUpperCase() conversion can be removed once the hex code is normalized in
|
||||||
|
// the admin API
|
||||||
|
const colorValue = colorOptions.find(({ value }) => value === colorField.value.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={composedRef}>
|
||||||
|
{liveText && <VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>}
|
||||||
|
|
||||||
|
{isDragging ? (
|
||||||
|
<StageDropPreview />
|
||||||
|
) : (
|
||||||
|
<Accordion
|
||||||
|
size="S"
|
||||||
|
variant="primary"
|
||||||
|
onToggle={() => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
trackUsage('willEditStage');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
expanded={isOpen}
|
||||||
|
shadow="tableShadow"
|
||||||
|
>
|
||||||
|
<AccordionToggle
|
||||||
|
title={nameField.value}
|
||||||
|
togglePosition="left"
|
||||||
|
action={
|
||||||
|
<>
|
||||||
|
{canDelete && (
|
||||||
|
<IconButton
|
||||||
|
background="transparent"
|
||||||
|
icon={<Trash />}
|
||||||
|
label={formatMessage({
|
||||||
|
id: 'Settings.review-workflows.stage.delete',
|
||||||
|
defaultMessage: 'Delete stage',
|
||||||
|
})}
|
||||||
|
noBorder
|
||||||
|
onClick={() => dispatch(deleteStage(id))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
background="transparent"
|
||||||
|
forwardedAs="div"
|
||||||
|
role="button"
|
||||||
|
noBorder
|
||||||
|
tabIndex={0}
|
||||||
|
data-handler-id={handlerId}
|
||||||
|
ref={dragRef}
|
||||||
|
label={formatMessage({
|
||||||
|
id: 'Settings.review-workflows.stage.drag',
|
||||||
|
defaultMessage: 'Drag',
|
||||||
|
})}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<Drag />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AccordionContent padding={6} background="neutral0" hasRadius>
|
||||||
|
<Grid gap={4}>
|
||||||
|
<GridItem col={6}>
|
||||||
|
<TextInput
|
||||||
|
{...nameField}
|
||||||
|
id={nameField.name}
|
||||||
|
label={formatMessage({
|
||||||
|
id: 'Settings.review-workflows.stage.name.label',
|
||||||
|
defaultMessage: 'Stage name',
|
||||||
|
})}
|
||||||
|
error={nameMeta.error ?? false}
|
||||||
|
onChange={(event) => {
|
||||||
|
nameField.onChange(event);
|
||||||
|
dispatch(updateStage(id, { name: event.target.value }));
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</GridItem>
|
||||||
|
|
||||||
|
<GridItem col={6}>
|
||||||
|
<Field
|
||||||
|
error={colorMeta?.error ?? false}
|
||||||
|
name={colorField.name}
|
||||||
|
id={colorField.name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Flex direction="column" gap={1} alignItems="stretch">
|
||||||
|
<FieldLabel>
|
||||||
|
{formatMessage({
|
||||||
|
id: 'content-manager.reviewWorkflows.stage.color',
|
||||||
|
defaultMessage: 'Color',
|
||||||
|
})}
|
||||||
|
</FieldLabel>
|
||||||
|
|
||||||
|
<ReactSelect
|
||||||
|
components={{ Option: OptionColor, SingleValue: SingleValueColor }}
|
||||||
|
error={colorMeta?.error}
|
||||||
|
inputId={colorField.name}
|
||||||
|
name={colorField.name}
|
||||||
|
options={colorOptions}
|
||||||
|
onChange={({ value }) => {
|
||||||
|
colorField.onChange({ target: { value } });
|
||||||
|
dispatch(updateStage(id, { color: value }));
|
||||||
|
}}
|
||||||
|
// If no color was found in all the valid theme colors it means a user
|
||||||
|
// has set a custom value e.g. through the content API. In that case we
|
||||||
|
// display the custom color and a "Custom" label.
|
||||||
|
value={
|
||||||
|
colorValue ?? {
|
||||||
|
value: colorField.value,
|
||||||
|
label: formatMessage({
|
||||||
|
id: 'Settings.review-workflows.stage.color.name.custom',
|
||||||
|
defaultMessage: 'Custom',
|
||||||
|
}),
|
||||||
|
color: colorField.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldError />
|
||||||
|
</Flex>
|
||||||
|
</Field>
|
||||||
|
</GridItem>
|
||||||
|
</Grid>
|
||||||
|
</AccordionContent>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Stage.propTypes = PropTypes.shape({
|
Stage.propTypes = PropTypes.shape({
|
||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.number.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
color: PropTypes.string.isRequired,
|
||||||
canDelete: PropTypes.bool.isRequired,
|
canDelete: PropTypes.bool.isRequired,
|
||||||
|
canReorder: PropTypes.bool.isRequired,
|
||||||
|
stagesCount: PropTypes.number.isRequired,
|
||||||
}).isRequired;
|
}).isRequired;
|
||||||
|
@ -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 { IntlProvider } from 'react-intl';
|
||||||
import { FormikProvider, useFormik } from 'formik';
|
import { FormikProvider, useFormik } from 'formik';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
import { DndProvider } from 'react-dnd';
|
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
|
|
||||||
import { ThemeProvider, lightTheme } from '@strapi/design-system';
|
import { ThemeProvider, lightTheme } from '@strapi/design-system';
|
||||||
|
|
||||||
@ -11,6 +13,8 @@ import configureStore from '../../../../../../../../../../admin/src/core/store/c
|
|||||||
import { Stage } from '../Stage';
|
import { Stage } from '../Stage';
|
||||||
import { reducer } from '../../../../reducer';
|
import { reducer } from '../../../../reducer';
|
||||||
|
|
||||||
|
import { STAGE_COLOR_DEFAULT } from '../../../../constants';
|
||||||
|
|
||||||
jest.mock('@strapi/helper-plugin', () => ({
|
jest.mock('@strapi/helper-plugin', () => ({
|
||||||
...jest.requireActual('@strapi/helper-plugin'),
|
...jest.requireActual('@strapi/helper-plugin'),
|
||||||
useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }),
|
useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }),
|
||||||
@ -18,8 +22,7 @@ jest.mock('@strapi/helper-plugin', () => ({
|
|||||||
|
|
||||||
const STAGES_FIXTURE = {
|
const STAGES_FIXTURE = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'stage-1',
|
index: 0,
|
||||||
index: 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ComponentFixture = (props) => {
|
const ComponentFixture = (props) => {
|
||||||
@ -30,6 +33,7 @@ const ComponentFixture = (props) => {
|
|||||||
initialValues: {
|
initialValues: {
|
||||||
stages: [
|
stages: [
|
||||||
{
|
{
|
||||||
|
color: STAGE_COLOR_DEFAULT,
|
||||||
name: 'something',
|
name: 'something',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -38,15 +42,17 @@ const ComponentFixture = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<FormikProvider value={formik}>
|
<Provider store={store}>
|
||||||
<IntlProvider locale="en" messages={{}}>
|
<FormikProvider value={formik}>
|
||||||
<ThemeProvider theme={lightTheme}>
|
<IntlProvider locale="en" messages={{}}>
|
||||||
<Stage {...STAGES_FIXTURE} {...props} />
|
<ThemeProvider theme={lightTheme}>
|
||||||
</ThemeProvider>
|
<Stage {...STAGES_FIXTURE} {...props} />
|
||||||
</IntlProvider>
|
</ThemeProvider>
|
||||||
</FormikProvider>
|
</IntlProvider>
|
||||||
</Provider>
|
</FormikProvider>
|
||||||
|
</Provider>
|
||||||
|
</DndProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -60,15 +66,20 @@ describe('Admin | Settings | Review Workflow | Stage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render a stage', async () => {
|
it('should render a stage', async () => {
|
||||||
const { getByRole, queryByRole } = setup();
|
const { container, getByRole, getByText, queryByRole } = setup();
|
||||||
|
|
||||||
expect(queryByRole('textbox')).not.toBeInTheDocument();
|
expect(queryByRole('textbox')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(getByRole('button'));
|
// open accordion; getByRole is not sufficient here, because the accordion
|
||||||
|
// does not have better identifiers
|
||||||
|
await user.click(container.querySelector('button[aria-expanded]'));
|
||||||
|
|
||||||
expect(queryByRole('textbox')).toBeInTheDocument();
|
expect(queryByRole('textbox')).toBeInTheDocument();
|
||||||
expect(getByRole('textbox').value).toBe(STAGES_FIXTURE.name);
|
expect(getByRole('textbox').value).toBe('something');
|
||||||
expect(getByRole('textbox').getAttribute('name')).toBe('stages.1.name');
|
expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name');
|
||||||
|
|
||||||
|
expect(getByText(/blue/i)).toBeInTheDocument();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
queryByRole('button', {
|
queryByRole('button', {
|
||||||
name: /delete stage/i,
|
name: /delete stage/i,
|
||||||
|
@ -45,11 +45,12 @@ function Stages({ stages }) {
|
|||||||
return (
|
return (
|
||||||
<Box key={`stage-${id}`} as="li">
|
<Box key={`stage-${id}`} as="li">
|
||||||
<Stage
|
<Stage
|
||||||
{...stage}
|
|
||||||
id={id}
|
id={id}
|
||||||
index={index}
|
index={index}
|
||||||
canDelete={stages.length > 1}
|
canDelete={stages.length > 1}
|
||||||
isOpen={!stage.id}
|
isOpen={!stage.id}
|
||||||
|
canReorder={stages.length > 1}
|
||||||
|
stagesCount={stages.length}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -4,13 +4,15 @@ import { IntlProvider } from 'react-intl';
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { FormikProvider, useFormik } from 'formik';
|
import { FormikProvider, useFormik } from 'formik';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { DndProvider } from 'react-dnd';
|
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
|
|
||||||
import { ThemeProvider, lightTheme } from '@strapi/design-system';
|
import { ThemeProvider, lightTheme } from '@strapi/design-system';
|
||||||
|
|
||||||
import configureStore from '../../../../../../../../../admin/src/core/store/configureStore';
|
import configureStore from '../../../../../../../../../admin/src/core/store/configureStore';
|
||||||
import { Stages } from '../Stages';
|
import { Stages } from '../Stages';
|
||||||
import { reducer } from '../../../reducer';
|
import { reducer } from '../../../reducer';
|
||||||
import { ACTION_SET_WORKFLOWS } from '../../../constants';
|
import { ACTION_SET_WORKFLOWS, STAGE_COLOR_DEFAULT } from '../../../constants';
|
||||||
import * as actions from '../../../actions';
|
import * as actions from '../../../actions';
|
||||||
|
|
||||||
// without mocking actions as ESM it is impossible to spy on named exports
|
// without mocking actions as ESM it is impossible to spy on named exports
|
||||||
@ -27,11 +29,13 @@ jest.mock('@strapi/helper-plugin', () => ({
|
|||||||
const STAGES_FIXTURE = [
|
const STAGES_FIXTURE = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
color: STAGE_COLOR_DEFAULT,
|
||||||
name: 'stage-1',
|
name: 'stage-1',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
color: STAGE_COLOR_DEFAULT,
|
||||||
name: 'stage-2',
|
name: 'stage-2',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -57,15 +61,17 @@ const ComponentFixture = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<FormikProvider value={formik}>
|
<Provider store={store}>
|
||||||
<IntlProvider locale="en" messages={{}}>
|
<FormikProvider value={formik}>
|
||||||
<ThemeProvider theme={lightTheme}>
|
<IntlProvider locale="en" messages={{}}>
|
||||||
<Stages stages={STAGES_FIXTURE} {...props} />
|
<ThemeProvider theme={lightTheme}>
|
||||||
</ThemeProvider>
|
<Stages stages={STAGES_FIXTURE} {...props} />
|
||||||
</IntlProvider>
|
</ThemeProvider>
|
||||||
</FormikProvider>
|
</IntlProvider>
|
||||||
</Provider>
|
</FormikProvider>
|
||||||
|
</Provider>
|
||||||
|
</DndProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,32 @@
|
|||||||
|
import { lightTheme } from '@strapi/design-system';
|
||||||
|
|
||||||
export const REDUX_NAMESPACE = 'settings_review-workflows';
|
export const REDUX_NAMESPACE = 'settings_review-workflows';
|
||||||
|
|
||||||
export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`;
|
export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`;
|
||||||
export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`;
|
export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`;
|
||||||
export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`;
|
export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`;
|
||||||
export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`;
|
export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`;
|
||||||
|
export const ACTION_UPDATE_STAGE_POSITION = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE_POSITION`;
|
||||||
|
|
||||||
|
export const STAGE_COLORS = {
|
||||||
|
primary600: 'Blue',
|
||||||
|
primary200: 'Lilac',
|
||||||
|
alternative600: 'Violet',
|
||||||
|
alternative200: 'Lavender',
|
||||||
|
success600: 'Green',
|
||||||
|
success200: 'Pale Green',
|
||||||
|
danger500: 'Cherry',
|
||||||
|
danger200: 'Pink',
|
||||||
|
warning600: 'Orange',
|
||||||
|
warning200: 'Yellow',
|
||||||
|
secondary600: 'Teal',
|
||||||
|
secondary200: 'Baby Blue',
|
||||||
|
neutral400: 'Gray',
|
||||||
|
neutral0: 'White',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STAGE_COLOR_DEFAULT = lightTheme.colors.primary600;
|
||||||
|
|
||||||
|
export const DRAG_DROP_TYPES = {
|
||||||
|
STAGE: 'stage',
|
||||||
|
};
|
||||||
|
@ -6,6 +6,8 @@ import {
|
|||||||
ACTION_DELETE_STAGE,
|
ACTION_DELETE_STAGE,
|
||||||
ACTION_ADD_STAGE,
|
ACTION_ADD_STAGE,
|
||||||
ACTION_UPDATE_STAGE,
|
ACTION_UPDATE_STAGE,
|
||||||
|
ACTION_UPDATE_STAGE_POSITION,
|
||||||
|
STAGE_COLOR_DEFAULT,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
export const initialState = {
|
export const initialState = {
|
||||||
@ -29,8 +31,18 @@ export function reducer(state = initialState, action) {
|
|||||||
|
|
||||||
draft.status = status;
|
draft.status = status;
|
||||||
|
|
||||||
if (workflows) {
|
if (workflows?.length > 0) {
|
||||||
const defaultWorkflow = workflows[0];
|
let defaultWorkflow = workflows[0];
|
||||||
|
|
||||||
|
// A safety net in case a stage does not have a color assigned;
|
||||||
|
// this normallly should not happen
|
||||||
|
defaultWorkflow = {
|
||||||
|
...defaultWorkflow,
|
||||||
|
stages: defaultWorkflow.stages.map((stage) => ({
|
||||||
|
...stage,
|
||||||
|
color: stage?.color ?? STAGE_COLOR_DEFAULT,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
draft.serverState.workflows = workflows;
|
draft.serverState.workflows = workflows;
|
||||||
draft.serverState.currentWorkflow = defaultWorkflow;
|
draft.serverState.currentWorkflow = defaultWorkflow;
|
||||||
@ -69,6 +81,7 @@ export function reducer(state = initialState, action) {
|
|||||||
|
|
||||||
draft.clientState.currentWorkflow.data.stages.push({
|
draft.clientState.currentWorkflow.data.stages.push({
|
||||||
...payload,
|
...payload,
|
||||||
|
color: payload?.color ?? STAGE_COLOR_DEFAULT,
|
||||||
__temp_key__: newTempKey,
|
__temp_key__: newTempKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -91,6 +104,27 @@ export function reducer(state = initialState, action) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case ACTION_UPDATE_STAGE_POSITION: {
|
||||||
|
const {
|
||||||
|
currentWorkflow: {
|
||||||
|
data: { stages },
|
||||||
|
},
|
||||||
|
} = state.clientState;
|
||||||
|
const { newIndex, oldIndex } = payload;
|
||||||
|
|
||||||
|
if (newIndex >= 0 && newIndex < stages.length) {
|
||||||
|
const stage = stages[oldIndex];
|
||||||
|
let newStages = [...stages];
|
||||||
|
|
||||||
|
newStages.splice(oldIndex, 1);
|
||||||
|
newStages.splice(newIndex, 0, stage);
|
||||||
|
|
||||||
|
draft.clientState.currentWorkflow.data.stages = newStages;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
ACTION_DELETE_STAGE,
|
ACTION_DELETE_STAGE,
|
||||||
ACTION_ADD_STAGE,
|
ACTION_ADD_STAGE,
|
||||||
ACTION_UPDATE_STAGE,
|
ACTION_UPDATE_STAGE,
|
||||||
|
ACTION_UPDATE_STAGE_POSITION,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
|
|
||||||
const WORKFLOWS_FIXTURE = [
|
const WORKFLOWS_FIXTURE = [
|
||||||
@ -13,6 +14,7 @@ const WORKFLOWS_FIXTURE = [
|
|||||||
stages: [
|
stages: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
color: 'red',
|
||||||
name: 'stage-1',
|
name: 'stage-1',
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -41,16 +43,26 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
|
|||||||
payload: { status: 'loading-state', workflows: WORKFLOWS_FIXTURE },
|
payload: { status: 'loading-state', workflows: WORKFLOWS_FIXTURE },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_WORKFLOW_FIXTURE = {
|
||||||
|
...WORKFLOWS_FIXTURE[0],
|
||||||
|
|
||||||
|
// stages without a color should have a default color assigned
|
||||||
|
stages: WORKFLOWS_FIXTURE[0].stages.map((stage) => ({
|
||||||
|
...stage,
|
||||||
|
color: stage?.color ?? '#4945ff',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
expect(reducer(state, action)).toStrictEqual(
|
expect(reducer(state, action)).toStrictEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
status: 'loading-state',
|
status: 'loading-state',
|
||||||
serverState: expect.objectContaining({
|
serverState: expect.objectContaining({
|
||||||
currentWorkflow: WORKFLOWS_FIXTURE[0],
|
currentWorkflow: DEFAULT_WORKFLOW_FIXTURE,
|
||||||
workflows: WORKFLOWS_FIXTURE,
|
workflows: WORKFLOWS_FIXTURE,
|
||||||
}),
|
}),
|
||||||
clientState: expect.objectContaining({
|
clientState: expect.objectContaining({
|
||||||
currentWorkflow: expect.objectContaining({
|
currentWorkflow: expect.objectContaining({
|
||||||
data: WORKFLOWS_FIXTURE[0],
|
data: DEFAULT_WORKFLOW_FIXTURE,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
hasDeletedServerStages: false,
|
hasDeletedServerStages: false,
|
||||||
}),
|
}),
|
||||||
@ -225,6 +237,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
|
|||||||
stages: expect.arrayContaining([
|
stages: expect.arrayContaining([
|
||||||
{
|
{
|
||||||
__temp_key__: 3,
|
__temp_key__: 3,
|
||||||
|
color: '#4945ff',
|
||||||
name: 'something',
|
name: 'something',
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@ -257,6 +270,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
|
|||||||
stages: expect.arrayContaining([
|
stages: expect.arrayContaining([
|
||||||
{
|
{
|
||||||
__temp_key__: 0,
|
__temp_key__: 0,
|
||||||
|
color: expect.any(String),
|
||||||
name: 'something',
|
name: 'something',
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@ -306,6 +320,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
|
|||||||
stages: expect.arrayContaining([
|
stages: expect.arrayContaining([
|
||||||
{
|
{
|
||||||
__temp_key__: 4,
|
__temp_key__: 4,
|
||||||
|
color: expect.any(String),
|
||||||
name: 'something',
|
name: 'something',
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@ -338,6 +353,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
|
|||||||
stages: expect.arrayContaining([
|
stages: expect.arrayContaining([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
color: 'red',
|
||||||
name: 'stage-1-modified',
|
name: 'stage-1-modified',
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@ -393,4 +409,112 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ACTION_UPDATE_STAGE_POSITION', () => {
|
||||||
|
const action = {
|
||||||
|
type: ACTION_UPDATE_STAGE_POSITION,
|
||||||
|
payload: { oldIndex: 0, newIndex: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
status: expect.any(String),
|
||||||
|
serverState: {
|
||||||
|
currentWorkflow: WORKFLOWS_FIXTURE[0],
|
||||||
|
},
|
||||||
|
clientState: {
|
||||||
|
currentWorkflow: {
|
||||||
|
data: WORKFLOWS_FIXTURE[0],
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(state, action)).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
clientState: expect.objectContaining({
|
||||||
|
currentWorkflow: expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
stages: [
|
||||||
|
expect.objectContaining({ name: 'stage-2' }),
|
||||||
|
expect.objectContaining({ name: 'stage-1' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
isDirty: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ACTION_UPDATE_STAGE_POSITION - does not update position if new index is smaller than 0', () => {
|
||||||
|
const action = {
|
||||||
|
type: ACTION_UPDATE_STAGE_POSITION,
|
||||||
|
payload: { oldIndex: 0, newIndex: -1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
status: expect.any(String),
|
||||||
|
serverState: {
|
||||||
|
currentWorkflow: WORKFLOWS_FIXTURE[0],
|
||||||
|
},
|
||||||
|
clientState: {
|
||||||
|
currentWorkflow: {
|
||||||
|
data: WORKFLOWS_FIXTURE[0],
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(state, action)).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
clientState: expect.objectContaining({
|
||||||
|
currentWorkflow: expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
stages: [
|
||||||
|
expect.objectContaining({ name: 'stage-1' }),
|
||||||
|
expect.objectContaining({ name: 'stage-2' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
isDirty: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ACTION_UPDATE_STAGE_POSITION - does not update position if new index is greater than the amount of stages', () => {
|
||||||
|
const action = {
|
||||||
|
type: ACTION_UPDATE_STAGE_POSITION,
|
||||||
|
payload: { oldIndex: 0, newIndex: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
status: expect.any(String),
|
||||||
|
serverState: {
|
||||||
|
currentWorkflow: WORKFLOWS_FIXTURE[0],
|
||||||
|
},
|
||||||
|
clientState: {
|
||||||
|
currentWorkflow: {
|
||||||
|
data: WORKFLOWS_FIXTURE[0],
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(state, action)).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
clientState: expect.objectContaining({
|
||||||
|
currentWorkflow: expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
stages: [
|
||||||
|
expect.objectContaining({ name: 'stage-1' }),
|
||||||
|
expect.objectContaining({ name: 'stage-2' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
isDirty: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,6 +8,8 @@ import { rest } from 'msw';
|
|||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import { useNotification } from '@strapi/helper-plugin';
|
import { useNotification } from '@strapi/helper-plugin';
|
||||||
import { ThemeProvider, lightTheme } from '@strapi/design-system';
|
import { ThemeProvider, lightTheme } from '@strapi/design-system';
|
||||||
|
import { DndProvider } from 'react-dnd';
|
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
|
|
||||||
import configureStore from '../../../../../../../admin/src/core/store/configureStore';
|
import configureStore from '../../../../../../../admin/src/core/store/configureStore';
|
||||||
import ReviewWorkflowsPage from '..';
|
import ReviewWorkflowsPage from '..';
|
||||||
@ -65,15 +67,17 @@ const ComponentFixture = () => {
|
|||||||
const store = configureStore([], [reducer]);
|
const store = configureStore([], [reducer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={client}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<Provider store={store}>
|
<QueryClientProvider client={client}>
|
||||||
<IntlProvider locale="en" messages={{}}>
|
<Provider store={store}>
|
||||||
<ThemeProvider theme={lightTheme}>
|
<IntlProvider locale="en" messages={{}}>
|
||||||
<ReviewWorkflowsPage />
|
<ThemeProvider theme={lightTheme}>
|
||||||
</ThemeProvider>
|
<ReviewWorkflowsPage />
|
||||||
</IntlProvider>
|
</ThemeProvider>
|
||||||
</Provider>
|
</IntlProvider>
|
||||||
</QueryClientProvider>
|
</Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</DndProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -112,7 +116,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('display stages', async () => {
|
test('display stages', async () => {
|
||||||
const { getByText } = setup();
|
const { getByText, queryByText } = setup();
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
|
||||||
|
|
||||||
await waitFor(() => expect(getByText('1 stage')).toBeInTheDocument());
|
await waitFor(() => expect(getByText('1 stage')).toBeInTheDocument());
|
||||||
expect(getByText('stage-1')).toBeInTheDocument();
|
expect(getByText('stage-1')).toBeInTheDocument();
|
||||||
@ -128,7 +134,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Save button is enabled after a stage has been added', async () => {
|
test('Save button is enabled after a stage has been added', async () => {
|
||||||
const { user, getByText, getByRole } = setup();
|
const { user, getByText, getByRole, queryByText } = setup();
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
getByRole('button', {
|
getByRole('button', {
|
||||||
@ -144,7 +152,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
|
|||||||
|
|
||||||
test('Successful Stage update', async () => {
|
test('Successful Stage update', async () => {
|
||||||
const toggleNotification = useNotification();
|
const toggleNotification = useNotification();
|
||||||
const { user, getByRole } = setup();
|
const { user, getByRole, queryByText } = setup();
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
getByRole('button', {
|
getByRole('button', {
|
||||||
@ -169,7 +179,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
|
|||||||
test('Stage update with error', async () => {
|
test('Stage update with error', async () => {
|
||||||
SHOULD_ERROR = true;
|
SHOULD_ERROR = true;
|
||||||
const toggleNotification = useNotification();
|
const toggleNotification = useNotification();
|
||||||
const { user, getByRole } = setup();
|
const { user, getByRole, queryByText } = setup();
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
getByRole('button', {
|
getByRole('button', {
|
||||||
@ -191,14 +203,18 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Does not show a delete button if only stage is left', () => {
|
test('Does not show a delete button if only stage is left', async () => {
|
||||||
const { queryByRole } = setup();
|
const { queryByRole, queryByText } = setup();
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
|
||||||
|
|
||||||
expect(queryByRole('button', { name: /delete stage/i })).not.toBeInTheDocument();
|
expect(queryByRole('button', { name: /delete stage/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Show confirmation dialog when a stage was deleted', async () => {
|
test('Show confirmation dialog when a stage was deleted', async () => {
|
||||||
const { user, getByRole, getAllByRole } = setup();
|
const { user, getByRole, getAllByRole, queryByText } = setup();
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
getByRole('button', {
|
getByRole('button', {
|
||||||
|
@ -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',
|
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 = {
|
module.exports = {
|
||||||
WORKFLOW_MODEL_UID: 'admin::workflow',
|
WORKFLOW_MODEL_UID: 'admin::workflow',
|
||||||
STAGE_MODEL_UID: 'admin::workflow-stage',
|
STAGE_MODEL_UID: 'admin::workflow-stage',
|
||||||
|
STAGE_DEFAULT_COLOR: '#4945FF',
|
||||||
ENTITY_STAGE_ATTRIBUTE: 'strapi_reviewWorkflows_stage',
|
ENTITY_STAGE_ATTRIBUTE: 'strapi_reviewWorkflows_stage',
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const { STAGE_DEFAULT_COLOR } = require('../../constants/workflows');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
schema: {
|
schema: {
|
||||||
collectionName: 'strapi_workflows_stages',
|
collectionName: 'strapi_workflows_stages',
|
||||||
@ -24,6 +26,11 @@ module.exports = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
configurable: false,
|
configurable: false,
|
||||||
},
|
},
|
||||||
|
color: {
|
||||||
|
type: 'string',
|
||||||
|
configurable: false,
|
||||||
|
default: STAGE_DEFAULT_COLOR,
|
||||||
|
},
|
||||||
workflow: {
|
workflow: {
|
||||||
type: 'relation',
|
type: 'relation',
|
||||||
target: 'admin::workflow',
|
target: 'admin::workflow',
|
||||||
|
@ -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 { features } = require('@strapi/strapi/lib/utils/ee');
|
||||||
const executeCERegister = require('../../server/register');
|
const executeCERegister = require('../../server/register');
|
||||||
const migrateAuditLogsTable = require('./migrations/audit-logs-table');
|
const migrateAuditLogsTable = require('./migrations/audit-logs-table');
|
||||||
|
const migrateReviewWorkflowStagesColor = require('./migrations/review-workflows-stages-color');
|
||||||
const createAuditLogsService = require('./services/audit-logs');
|
const createAuditLogsService = require('./services/audit-logs');
|
||||||
const reviewWorkflowsMiddlewares = require('./middlewares/review-workflows');
|
const reviewWorkflowsMiddlewares = require('./middlewares/review-workflows');
|
||||||
const { getService } = require('./utils');
|
const { getService } = require('./utils');
|
||||||
@ -17,6 +18,7 @@ module.exports = async ({ strapi }) => {
|
|||||||
await auditLogsService.register();
|
await auditLogsService.register();
|
||||||
}
|
}
|
||||||
if (features.isEnabled('review-workflows')) {
|
if (features.isEnabled('review-workflows')) {
|
||||||
|
strapi.hook('strapi::content-types.afterSync').register(migrateReviewWorkflowStagesColor);
|
||||||
const reviewWorkflowService = getService('review-workflows');
|
const reviewWorkflowService = getService('review-workflows');
|
||||||
|
|
||||||
reviewWorkflowsMiddlewares.contentTypeMiddleware(strapi);
|
reviewWorkflowsMiddlewares.contentTypeMiddleware(strapi);
|
||||||
|
@ -208,7 +208,7 @@ function getDiffBetweenStages(sourceStages, comparisonStages) {
|
|||||||
|
|
||||||
if (!srcStage) {
|
if (!srcStage) {
|
||||||
acc.created.push(stageToCompare);
|
acc.created.push(stageToCompare);
|
||||||
} else if (srcStage.name !== stageToCompare.name) {
|
} else if (srcStage.name !== stageToCompare.name || srcStage.color !== stageToCompare.color) {
|
||||||
acc.updated.push(stageToCompare);
|
acc.updated.push(stageToCompare);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
|
@ -5,9 +5,15 @@ const { yup, validateYupSchema } = require('@strapi/utils');
|
|||||||
const stageObject = yup.object().shape({
|
const stageObject = yup.object().shape({
|
||||||
id: yup.number().integer().min(1),
|
id: yup.number().integer().min(1),
|
||||||
name: yup.string().max(255).required(),
|
name: yup.string().max(255).required(),
|
||||||
|
color: yup.string().matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i), // hex color
|
||||||
});
|
});
|
||||||
|
|
||||||
const validateUpdateStagesSchema = yup.array().of(stageObject).required();
|
const validateUpdateStagesSchema = yup
|
||||||
|
.array()
|
||||||
|
.of(stageObject)
|
||||||
|
.required()
|
||||||
|
.max(200, 'You can not create more than 200 stages');
|
||||||
|
|
||||||
const validateUpdateStageOnEntity = yup
|
const validateUpdateStageOnEntity = yup
|
||||||
.object()
|
.object()
|
||||||
.shape({
|
.shape({
|
||||||
|
@ -26,7 +26,6 @@ const TableHead = ({
|
|||||||
const [{ query }, setQuery] = useQueryParams();
|
const [{ query }, setQuery] = useQueryParams();
|
||||||
const sort = query?.sort || '';
|
const sort = query?.sort || '';
|
||||||
const [sortBy, sortOrder] = sort.split(':');
|
const [sortBy, sortOrder] = sort.split(':');
|
||||||
|
|
||||||
const isIndeterminate = !areAllEntriesSelected && entriesToDelete.length > 0;
|
const isIndeterminate = !areAllEntriesSelected && entriesToDelete.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -45,54 +44,67 @@ const TableHead = ({
|
|||||||
/>
|
/>
|
||||||
</Th>
|
</Th>
|
||||||
)}
|
)}
|
||||||
{headers.map(({ name, metadatas: { sortable: isSortable, label } }) => {
|
{headers.map(
|
||||||
const isSorted = sortBy === name;
|
({ fieldSchema, name, metadatas: { sortable: isSortable, label, mainField } }) => {
|
||||||
const isUp = sortOrder === 'ASC';
|
let isSorted = sortBy === name;
|
||||||
|
const isUp = sortOrder === 'ASC';
|
||||||
|
|
||||||
const sortLabel = formatMessage(
|
// relations always have to be sorted by their main field instead of only the
|
||||||
{ id: 'components.TableHeader.sort', defaultMessage: 'Sort on {label}' },
|
// attribute name; sortBy e.g. looks like: &sortBy=attributeName[mainField]:ASC
|
||||||
{ label }
|
if (fieldSchema?.type === 'relation' && mainField) {
|
||||||
);
|
isSorted = sortBy === `${name}[${mainField}]`;
|
||||||
|
|
||||||
const handleClickSort = (shouldAllowClick = true) => {
|
|
||||||
if (isSortable && shouldAllowClick) {
|
|
||||||
const nextSortOrder = isSorted && sortOrder === 'ASC' ? 'DESC' : 'ASC';
|
|
||||||
const nextSort = `${name}:${nextSortOrder}`;
|
|
||||||
|
|
||||||
setQuery({
|
|
||||||
sort: nextSort,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const sortLabel = formatMessage(
|
||||||
<Th
|
{ id: 'components.TableHeader.sort', defaultMessage: 'Sort on {label}' },
|
||||||
key={name}
|
{ label }
|
||||||
action={
|
);
|
||||||
isSorted && (
|
|
||||||
<IconButton
|
const handleClickSort = (shouldAllowClick = true) => {
|
||||||
label={sortLabel}
|
if (isSortable && shouldAllowClick) {
|
||||||
onClick={handleClickSort}
|
let nextSort = name;
|
||||||
icon={isSorted && <SortIcon isUp={isUp} />}
|
|
||||||
noBorder
|
// relations always have to be sorted by their main field instead of only the
|
||||||
/>
|
// attribute name; nextSort e.g. looks like: &nextSort=attributeName[mainField]:ASC
|
||||||
)
|
if (fieldSchema?.type === 'relation' && mainField) {
|
||||||
|
nextSort = `${name}[${mainField}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuery({
|
||||||
|
sort: `${nextSort}:${isSorted && sortOrder === 'ASC' ? 'DESC' : 'ASC'}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
>
|
};
|
||||||
<Tooltip label={isSortable ? sortLabel : label}>
|
|
||||||
<Typography
|
return (
|
||||||
textColor="neutral600"
|
<Th
|
||||||
as={!isSorted && isSortable ? 'button' : 'span'}
|
key={name}
|
||||||
label={label}
|
action={
|
||||||
onClick={() => handleClickSort(!isSorted)}
|
isSorted && (
|
||||||
variant="sigma"
|
<IconButton
|
||||||
>
|
label={sortLabel}
|
||||||
{label}
|
onClick={handleClickSort}
|
||||||
</Typography>
|
icon={isSorted && <SortIcon isUp={isUp} />}
|
||||||
</Tooltip>
|
noBorder
|
||||||
</Th>
|
/>
|
||||||
);
|
)
|
||||||
})}
|
}
|
||||||
|
>
|
||||||
|
<Tooltip label={isSortable ? sortLabel : label}>
|
||||||
|
<Typography
|
||||||
|
textColor="neutral600"
|
||||||
|
as={!isSorted && isSortable ? 'button' : 'span'}
|
||||||
|
label={label}
|
||||||
|
onClick={() => handleClickSort(!isSorted)}
|
||||||
|
variant="sigma"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
</Th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
|
||||||
{withBulkActions && (
|
{withBulkActions && (
|
||||||
<Th>
|
<Th>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user