Merge pull request #16546 from strapi/feature/review-workflow-1-d-and-d-stages

Settings: Add drag and drop to allow reordering of stages
This commit is contained in:
Gustav Hansen 2023-04-28 14:03:58 +02:00 committed by GitHub
commit 84a9da4b2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 502 additions and 117 deletions

View File

@ -18,12 +18,24 @@ import { Check } from '@strapi/icons';
import { Stages } from './components/Stages';
import { reducer, initialState } from './reducer';
import { REDUX_NAMESPACE } from './constants';
import { REDUX_NAMESPACE, DRAG_DROP_TYPES } from './constants';
import { useInjectReducer } from '../../../../../../admin/src/hooks/useInjectReducer';
import { useReviewWorkflows } from './hooks/useReviewWorkflows';
import { setWorkflows } from './actions';
import { getWorkflowValidationSchema } from './utils/getWorkflowValidationSchema';
import adminPermissions from '../../../../../../admin/src/permissions';
import { StageDragPreview } from './components/StageDragPreview';
import { DragLayer } from '../../../../../../admin/src/components/DragLayer';
function renderDragLayerItem({ type, item }) {
switch (type) {
case DRAG_DROP_TYPES.STAGE:
return <StageDragPreview {...item} />;
default:
return null;
}
}
export function ReviewWorkflowsPage() {
const { trackUsage } = useTracking();
@ -135,6 +147,8 @@ export function ReviewWorkflowsPage() {
})}
/>
<Main tabIndex={-1}>
<DragLayer renderItem={renderDragLayerItem} />
<FormikProvider value={formik}>
<Form onSubmit={formik.handleSubmit}>
<HeaderLayout

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import * as React from 'react';
import PropTypes from 'prop-types';
import { useField } from 'formik';
import { useIntl } from 'react-intl';
@ -7,6 +7,7 @@ import {
Accordion,
AccordionToggle,
AccordionContent,
Box,
Field,
FieldLabel,
FieldError,
@ -15,24 +16,151 @@ import {
GridItem,
IconButton,
TextInput,
VisuallyHidden,
} from '@strapi/design-system';
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';
const AVAILABLE_COLORS = getAvailableStageColors();
export function Stage({ id, index, canDelete, isOpen: isOpenDefault = false }) {
function StageDropPreview() {
return (
<Box
background="primary100"
borderStyle="dashed"
borderColor="primary600"
borderWidth="1px"
display="block"
hasRadius
padding={6}
shadow="tableShadow"
/>
);
}
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 [isOpen, setIsOpen] = useState(isOpenDefault);
const dispatch = useDispatch();
const [isOpen, setIsOpen] = React.useState(isOpenDefault);
const [nameField, nameMeta] = useField(`stages.${index}.name`);
const [colorField, colorMeta] = useField(`stages.${index}.color`);
const dispatch = useDispatch();
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(
@ -46,91 +174,119 @@ export function Stage({ id, index, canDelete, isOpen: isOpenDefault = false }) {
}));
return (
<Accordion
size="S"
variant="primary"
onToggle={() => {
setIsOpen(!isOpen);
<Box ref={composedRef}>
{liveText && <VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>}
if (!isOpen) {
trackUsage('willEditStage');
}
}}
expanded={isOpen}
shadow="tableShadow"
>
<AccordionToggle
title={nameField.value}
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
{...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>
{isDragging ? (
<StageDropPreview />
) : (
<Accordion
size="S"
variant="primary"
onToggle={() => {
setIsOpen(!isOpen);
<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',
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',
})}
</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 }));
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 }));
}}
value={colorOptions.find(({ value }) => value === colorField.value)}
required
/>
</GridItem>
<FieldError />
</Flex>
</Field>
</GridItem>
</Grid>
</AccordionContent>
</Accordion>
<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 }));
}}
value={colorOptions.find(({ value }) => value === colorField.value)}
/>
<FieldError />
</Flex>
</Field>
</GridItem>
</Grid>
</AccordionContent>
</Accordion>
)}
</Box>
);
}
@ -138,4 +294,6 @@ Stage.propTypes = PropTypes.shape({
id: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
canDelete: PropTypes.bool.isRequired,
canReorder: PropTypes.bool.isRequired,
stagesCount: PropTypes.number.isRequired,
}).isRequired;

View File

@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
import { FormikProvider, useFormik } from 'formik';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
@ -40,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>
);
};
@ -62,12 +66,13 @@ describe('Admin | Settings | Review Workflow | Stage', () => {
});
it('should render a stage', async () => {
const { getByRole, getByText, queryByRole } = setup();
const { container, getByRole, getByText, queryByRole } = setup();
expect(queryByRole('textbox')).not.toBeInTheDocument();
// open accordion
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('something');

View File

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

View File

@ -4,6 +4,8 @@ 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';
@ -59,15 +61,17 @@ const ComponentFixture = (props) => {
});
return (
<Provider store={store}>
<FormikProvider value={formik}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<Stages stages={STAGES_FIXTURE} {...props} />
</ThemeProvider>
</IntlProvider>
</FormikProvider>
</Provider>
<DndProvider backend={HTML5Backend}>
<Provider store={store}>
<FormikProvider value={formik}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<Stages stages={STAGES_FIXTURE} {...props} />
</ThemeProvider>
</IntlProvider>
</FormikProvider>
</Provider>
</DndProvider>
);
};

View File

@ -6,6 +6,7 @@ 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',
@ -25,3 +26,7 @@ export const STAGE_COLORS = {
};
export const STAGE_COLOR_DEFAULT = lightTheme.colors.primary600;
export const DRAG_DROP_TYPES = {
STAGE: 'stage',
};

View File

@ -6,6 +6,7 @@ import {
ACTION_DELETE_STAGE,
ACTION_ADD_STAGE,
ACTION_UPDATE_STAGE,
ACTION_UPDATE_STAGE_POSITION,
STAGE_COLOR_DEFAULT,
} from '../constants';
@ -103,6 +104,27 @@ export function reducer(state = initialState, action) {
break;
}
case ACTION_UPDATE_STAGE_POSITION: {
const {
currentWorkflow: {
data: { stages },
},
} = state.clientState;
const { newIndex, oldIndex } = payload;
if (newIndex >= 0 && newIndex < stages.length) {
const stage = stages[oldIndex];
let newStages = [...stages];
newStages.splice(oldIndex, 1);
newStages.splice(newIndex, 0, stage);
draft.clientState.currentWorkflow.data.stages = newStages;
}
break;
}
default:
break;
}

View File

@ -5,6 +5,7 @@ import {
ACTION_DELETE_STAGE,
ACTION_ADD_STAGE,
ACTION_UPDATE_STAGE,
ACTION_UPDATE_STAGE_POSITION,
} from '../../constants';
const WORKFLOWS_FIXTURE = [
@ -408,4 +409,112 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
})
);
});
test('ACTION_UPDATE_STAGE_POSITION', () => {
const action = {
type: ACTION_UPDATE_STAGE_POSITION,
payload: { oldIndex: 0, newIndex: 1 },
};
state = {
status: expect.any(String),
serverState: {
currentWorkflow: WORKFLOWS_FIXTURE[0],
},
clientState: {
currentWorkflow: {
data: WORKFLOWS_FIXTURE[0],
isDirty: false,
},
},
};
expect(reducer(state, action)).toStrictEqual(
expect.objectContaining({
clientState: expect.objectContaining({
currentWorkflow: expect.objectContaining({
data: expect.objectContaining({
stages: [
expect.objectContaining({ name: 'stage-2' }),
expect.objectContaining({ name: 'stage-1' }),
],
}),
isDirty: true,
}),
}),
})
);
});
test('ACTION_UPDATE_STAGE_POSITION - does not update position if new index is smaller than 0', () => {
const action = {
type: ACTION_UPDATE_STAGE_POSITION,
payload: { oldIndex: 0, newIndex: -1 },
};
state = {
status: expect.any(String),
serverState: {
currentWorkflow: WORKFLOWS_FIXTURE[0],
},
clientState: {
currentWorkflow: {
data: WORKFLOWS_FIXTURE[0],
isDirty: false,
},
},
};
expect(reducer(state, action)).toStrictEqual(
expect.objectContaining({
clientState: expect.objectContaining({
currentWorkflow: expect.objectContaining({
data: expect.objectContaining({
stages: [
expect.objectContaining({ name: 'stage-1' }),
expect.objectContaining({ name: 'stage-2' }),
],
}),
isDirty: false,
}),
}),
})
);
});
test('ACTION_UPDATE_STAGE_POSITION - does not update position if new index is greater than the amount of stages', () => {
const action = {
type: ACTION_UPDATE_STAGE_POSITION,
payload: { oldIndex: 0, newIndex: 3 },
};
state = {
status: expect.any(String),
serverState: {
currentWorkflow: WORKFLOWS_FIXTURE[0],
},
clientState: {
currentWorkflow: {
data: WORKFLOWS_FIXTURE[0],
isDirty: false,
},
},
};
expect(reducer(state, action)).toStrictEqual(
expect.objectContaining({
clientState: expect.objectContaining({
currentWorkflow: expect.objectContaining({
data: expect.objectContaining({
stages: [
expect.objectContaining({ name: 'stage-1' }),
expect.objectContaining({ name: 'stage-2' }),
],
}),
isDirty: false,
}),
}),
})
);
});
});

View File

@ -8,6 +8,8 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { useNotification } from '@strapi/helper-plugin';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import configureStore from '../../../../../../../admin/src/core/store/configureStore';
import ReviewWorkflowsPage from '..';
@ -65,15 +67,17 @@ const ComponentFixture = () => {
const store = configureStore([], [reducer]);
return (
<QueryClientProvider client={client}>
<Provider store={store}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<ReviewWorkflowsPage />
</ThemeProvider>
</IntlProvider>
</Provider>
</QueryClientProvider>
<DndProvider backend={HTML5Backend}>
<QueryClientProvider client={client}>
<Provider store={store}>
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<ReviewWorkflowsPage />
</ThemeProvider>
</IntlProvider>
</Provider>
</QueryClientProvider>
</DndProvider>
);
};