From 9978e0fda3add2d0cafd656c03c81ea0ccc91216 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Wed, 26 Apr 2023 14:15:41 +0200 Subject: [PATCH 1/2] Settings: Add drag and drop to allow reodering of stages --- .../pages/ReviewWorkflows/ReviewWorkflows.js | 16 +- .../pages/ReviewWorkflows/actions/index.js | 11 + .../StageDragPreview/StageDragPreview.js | 45 +++ .../components/StageDragPreview/index.js | 1 + .../components/Stages/Stage/Stage.js | 328 +++++++++++++----- .../components/Stages/Stages.js | 9 +- .../pages/ReviewWorkflows/constants.js | 5 + .../pages/ReviewWorkflows/reducer/index.js | 22 ++ .../reducer/tests/index.test.js | 109 ++++++ 9 files changed, 459 insertions(+), 87 deletions(-) create mode 100644 packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/StageDragPreview.js create mode 100644 packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/index.js diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js index 44e729e23b..c8a3ec61c3 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/ReviewWorkflows.js @@ -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 ; + + default: + return null; + } +} export function ReviewWorkflowsPage() { const { trackUsage } = useTracking(); @@ -135,6 +147,8 @@ export function ReviewWorkflowsPage() { })} />
+ +
theme.colors.neutral600}; + } +`; + +export function StageDragPreview({ name }) { + return ( + + + + + + {name} + + ); +} + +StageDragPreview.propTypes = { + name: PropTypes.string.isRequired, +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/index.js new file mode 100644 index 0000000000..a771750e68 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/StageDragPreview/index.js @@ -0,0 +1 @@ +export * from './StageDragPreview'; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js index 6b4061ae5a..9985f3be06 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js @@ -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 ( + + ); +} + +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 ( - { - setIsOpen(!isOpen); + + {liveText && {liveText}} - if (!isOpen) { - trackUsage('willEditStage'); - } - }} - expanded={isOpen} - shadow="tableShadow" - > - dispatch(deleteStage(id))} - label={formatMessage({ - id: 'Settings.review-workflows.stage.delete', - defaultMessage: 'Delete stage', - })} - icon={} - /> - ) : null - } - /> - - - - { - nameField.onChange(event); - dispatch(updateStage(id, { name: event.target.value })); - }} - required - /> - + {isDragging ? ( + + ) : ( + { + setIsOpen(!isOpen); - - - - - {formatMessage({ - id: 'content-manager.reviewWorkflows.stage.color', - defaultMessage: 'Color', + if (!isOpen) { + trackUsage('willEditStage'); + } + }} + expanded={isOpen} + shadow="tableShadow" + > + + {canDelete && ( + } + label={formatMessage({ + id: 'Settings.review-workflows.stage.delete', + defaultMessage: 'Delete stage', + })} + noBorder + onClick={() => dispatch(deleteStage(id))} + /> + )} + + - - { - colorField.onChange({ target: { value } }); - dispatch(updateStage(id, { color: value })); + onClick={(e) => e.stopPropagation()} + onKeyDown={handleKeyDown} + > + + + + } + /> + + + + { + nameField.onChange(event); + dispatch(updateStage(id, { name: event.target.value })); }} - value={colorOptions.find(({ value }) => value === colorField.value)} + required /> + - - - - - - - + + + + + {formatMessage({ + id: 'content-manager.reviewWorkflows.stage.color', + defaultMessage: 'Color', + })} + + + { + colorField.onChange({ target: { value } }); + dispatch(updateStage(id, { color: value })); + }} + value={colorOptions.find(({ value }) => value === colorField.value)} + /> + + + + + + + + + )} + ); } @@ -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; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js index e030090a71..3bb55d8459 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js @@ -44,7 +44,14 @@ function Stages({ stages }) { return ( - 1} isOpen={!stage.id} /> + 1} + isOpen={!stage.id} + canReorder={stages.length > 1} + stagesCount={stages.length} + /> ); })} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js index 9b33e82ba2..388ab4d8a1 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js @@ -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', +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js index cf6b409251..7a9d6db2bc 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js @@ -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; } diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js index 544c8e8b6f..60563a58a9 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js @@ -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, + }), + }), + }) + ); + }); }); From 12638795e9eb38ca18fcd089c48984feeb424727 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Fri, 28 Apr 2023 12:51:16 +0200 Subject: [PATCH 2/2] Chore: Update tests --- .../Stages/Stage/tests/Stage.test.js | 29 +++++++++++-------- .../components/Stages/tests/Stages.test.js | 22 ++++++++------ .../tests/ReviewWorkflows.test.js | 22 ++++++++------ 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js index 7e205f612c..2607a5f2d3 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js @@ -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 ( - - - - - - - - - + + + + + + + + + + + ); }; @@ -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'); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js index b02015c66b..c6dc9bb58f 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js @@ -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 ( - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js index 34da591caa..b279883011 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js @@ -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 ( - - - - - - - - - + + + + + + + + + + + ); };