Settings: Implement color select for stages

This commit is contained in:
Gustav Hansen 2023-04-25 17:40:48 +02:00
parent 4b4d64bbb6
commit ef3d7ff09a
15 changed files with 275 additions and 39 deletions

View File

@ -7,23 +7,43 @@ import {
Accordion,
AccordionToggle,
AccordionContent,
Field,
FieldLabel,
FieldError,
Flex,
Grid,
GridItem,
IconButton,
TextInput,
} from '@strapi/design-system';
import { useTracking } from '@strapi/helper-plugin';
import { ReactSelect, useTracking } from '@strapi/helper-plugin';
import { Trash } from '@strapi/icons';
import { deleteStage, updateStage } from '../../../actions';
import { getAvailableStageColors } from '../../../utils/colors';
import { OptionColor } from './components/OptionColor';
import { SingleValueColor } from './components/SingleValueColor';
function Stage({ id, name, index, canDelete, isOpen: isOpenDefault = false }) {
const AVAILABLE_COLORS = getAvailableStageColors();
export function Stage({ id, index, canDelete, isOpen: isOpenDefault = false }) {
const { formatMessage } = useIntl();
const { trackUsage } = useTracking();
const [isOpen, setIsOpen] = useState(isOpenDefault);
const fieldIdentifier = `stages.${index}.name`;
const [field, meta] = useField(fieldIdentifier);
const [nameField, nameMeta] = useField(`stages.${index}.name`);
const [colorField, colorMeta] = useField(`stages.${index}.color`);
const dispatch = useDispatch();
const colorOptions = AVAILABLE_COLORS.map(({ themeColorName, hex, name }) => ({
value: hex,
label: formatMessage(
{
id: 'Settings.review-workflows.stage.color.name',
defaultMessage: '{name}',
},
{ name }
),
themeColorName,
}));
return (
<Accordion
@ -40,7 +60,7 @@ function Stage({ id, name, index, canDelete, isOpen: isOpenDefault = false }) {
shadow="tableShadow"
>
<AccordionToggle
title={name}
title={nameField.value}
togglePosition="left"
action={
canDelete ? (
@ -61,30 +81,59 @@ function Stage({ id, name, index, canDelete, isOpen: isOpenDefault = false }) {
<Grid gap={4}>
<GridItem col={6}>
<TextInput
{...field}
id={fieldIdentifier}
value={name}
{...nameField}
id={nameField.name}
label={formatMessage({
id: 'Settings.review-workflows.stage.name.label',
defaultMessage: 'Stage name',
})}
error={meta.error ?? false}
error={nameMeta.error ?? false}
onChange={(event) => {
field.onChange(event);
nameField.onChange(event);
dispatch(updateStage(id, { name: event.target.value }));
}}
required
/>
</GridItem>
<GridItem col={6}>
<Field error={colorMeta?.error ?? false} 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}
id={colorField.name}
name={colorField.name}
options={colorOptions}
onChange={({ value }) => {
colorField.onChange({ target: { value } });
dispatch(updateStage(id, { color: value }));
}}
value={{
value: colorField.value,
label: colorOptions.find(({ value }) => value === colorField.value).label,
}}
/>
<FieldError />
</Flex>
</Field>
</GridItem>
</Grid>
</AccordionContent>
</Accordion>
);
}
export { Stage };
Stage.propTypes = PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
canDelete: PropTypes.bool.isRequired,
}).isRequired;

View File

@ -0,0 +1,29 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { components } from 'react-select';
import { Box, Flex, Typography } from '@strapi/design-system';
export function OptionColor({ children, ...props }) {
const { value } = props.data;
return (
<components.Option {...props}>
<Flex alignItems="center" gap={2}>
<Box height={2} background={value} hasRadius width={2} />
<Typography textColor="neutral800" ellipsis>
{children}
</Typography>
</Flex>
</components.Option>
);
}
OptionColor.propTypes = {
children: PropTypes.node.isRequired,
data: PropTypes.shape({
label: PropTypes.string,
themeColorName: PropTypes.string,
value: PropTypes.string,
}).isRequired,
};

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { components } from 'react-select';
import { Box, Flex, Typography } from '@strapi/design-system';
export function SingleValueColor({ children, ...props }) {
const { value } = props.data;
return (
<components.SingleValue {...props}>
<Flex alignItems="center" gap={2}>
<Box height={2} background={value} hasRadius width={2} />
<Typography textColor="neutral800" ellipsis>
{children}
</Typography>
</Flex>
</components.SingleValue>
);
}
SingleValueColor.propTypes = {
children: PropTypes.node.isRequired,
data: PropTypes.shape({
value: PropTypes.string,
}).isRequired,
};

View File

@ -11,6 +11,8 @@ import configureStore from '../../../../../../../../../../admin/src/core/store/c
import { Stage } from '../Stage';
import { reducer } from '../../../../reducer';
import { STAGE_COLOR_DEFAULT } from '../../../../constants';
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }),
@ -18,8 +20,7 @@ jest.mock('@strapi/helper-plugin', () => ({
const STAGES_FIXTURE = {
id: 1,
name: 'stage-1',
index: 1,
index: 0,
};
const ComponentFixture = (props) => {
@ -30,6 +31,7 @@ const ComponentFixture = (props) => {
initialValues: {
stages: [
{
color: STAGE_COLOR_DEFAULT,
name: 'something',
},
],
@ -60,15 +62,19 @@ describe('Admin | Settings | Review Workflow | Stage', () => {
});
it('should render a stage', async () => {
const { getByRole, queryByRole } = setup();
const { getByRole, getByText, queryByRole } = setup();
expect(queryByRole('textbox')).not.toBeInTheDocument();
// open accordion
await user.click(getByRole('button'));
expect(queryByRole('textbox')).toBeInTheDocument();
expect(getByRole('textbox').value).toBe(STAGES_FIXTURE.name);
expect(getByRole('textbox').getAttribute('name')).toBe('stages.1.name');
expect(getByRole('textbox').value).toBe('something');
expect(getByRole('textbox').getAttribute('name')).toBe('stages.0.name');
expect(getByText(/blue/i)).toBeInTheDocument();
expect(
queryByRole('button', {
name: /delete stage/i,

View File

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

View File

@ -10,7 +10,7 @@ import { ThemeProvider, lightTheme } from '@strapi/design-system';
import configureStore from '../../../../../../../../../admin/src/core/store/configureStore';
import { Stages } from '../Stages';
import { reducer } from '../../../reducer';
import { ACTION_SET_WORKFLOWS } from '../../../constants';
import { ACTION_SET_WORKFLOWS, STAGE_COLOR_DEFAULT } from '../../../constants';
import * as actions from '../../../actions';
// without mocking actions as ESM it is impossible to spy on named exports
@ -27,11 +27,13 @@ jest.mock('@strapi/helper-plugin', () => ({
const STAGES_FIXTURE = [
{
id: 1,
color: STAGE_COLOR_DEFAULT,
name: 'stage-1',
},
{
id: 2,
color: STAGE_COLOR_DEFAULT,
name: 'stage-2',
},
];

View File

@ -1,6 +1,27 @@
import { lightTheme } from '@strapi/design-system';
export const REDUX_NAMESPACE = 'settings_review-workflows';
export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`;
export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`;
export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`;
export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`;
export const 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;

View File

@ -6,6 +6,7 @@ import {
ACTION_DELETE_STAGE,
ACTION_ADD_STAGE,
ACTION_UPDATE_STAGE,
STAGE_COLOR_DEFAULT,
} from '../constants';
export const initialState = {
@ -29,8 +30,18 @@ export function reducer(state = initialState, action) {
draft.status = status;
if (workflows) {
const defaultWorkflow = workflows[0];
if (workflows?.length > 0) {
let defaultWorkflow = workflows[0];
// A safety net in case a stage does not have a color assigned;
// this normallly should not happen
defaultWorkflow = {
...defaultWorkflow,
stages: defaultWorkflow.stages.map((stage) => ({
...stage,
color: stage?.color ?? STAGE_COLOR_DEFAULT,
})),
};
draft.serverState.workflows = workflows;
draft.serverState.currentWorkflow = defaultWorkflow;
@ -69,6 +80,7 @@ export function reducer(state = initialState, action) {
draft.clientState.currentWorkflow.data.stages.push({
...payload,
color: payload?.color ?? STAGE_COLOR_DEFAULT,
__temp_key__: newTempKey,
});

View File

@ -13,6 +13,7 @@ const WORKFLOWS_FIXTURE = [
stages: [
{
id: 1,
color: 'red',
name: 'stage-1',
},
@ -41,16 +42,26 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
payload: { status: 'loading-state', workflows: WORKFLOWS_FIXTURE },
};
const DEFAULT_WORKFLOW_FIXTURE = {
...WORKFLOWS_FIXTURE[0],
// stages without a color should have a default color assigned
stages: WORKFLOWS_FIXTURE[0].stages.map((stage) => ({
...stage,
color: stage?.color ?? '#4945ff',
})),
};
expect(reducer(state, action)).toStrictEqual(
expect.objectContaining({
status: 'loading-state',
serverState: expect.objectContaining({
currentWorkflow: WORKFLOWS_FIXTURE[0],
currentWorkflow: DEFAULT_WORKFLOW_FIXTURE,
workflows: WORKFLOWS_FIXTURE,
}),
clientState: expect.objectContaining({
currentWorkflow: expect.objectContaining({
data: WORKFLOWS_FIXTURE[0],
data: DEFAULT_WORKFLOW_FIXTURE,
isDirty: false,
hasDeletedServerStages: false,
}),
@ -338,6 +349,7 @@ describe('Admin | Settings | Review Workflows | reducer', () => {
stages: expect.arrayContaining([
{
id: 1,
color: 'red',
name: 'stage-1-modified',
},
]),

View File

@ -1,5 +1,5 @@
import React from 'react';
import { act, fireEvent, render } from '@testing-library/react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { QueryClientProvider, QueryClient } from 'react-query';
@ -111,8 +111,10 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
expect(queryByText('Workflow is loading')).not.toBeInTheDocument();
});
test('display stages', () => {
const { getByText } = setup();
test('display stages', async () => {
const { getByText, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
expect(getByText('1 stage')).toBeInTheDocument();
expect(getByText('stage-1')).toBeInTheDocument();
@ -128,7 +130,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
});
test('Save button is enabled after a stage has been added', async () => {
const { user, getByText, getByRole } = setup();
const { user, getByText, getByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
await user.click(
getByRole('button', {
@ -144,7 +148,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
test('Successful Stage update', async () => {
const toggleNotification = useNotification();
const { user, getByRole } = setup();
const { user, getByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
await user.click(
getByRole('button', {
@ -169,7 +175,9 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
test('Stage update with error', async () => {
SHOULD_ERROR = true;
const toggleNotification = useNotification();
const { user, getByRole } = setup();
const { user, getByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
await user.click(
getByRole('button', {
@ -191,14 +199,18 @@ describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => {
});
});
test('Does not show a delete button if only stage is left', () => {
const { queryByRole } = setup();
test('Does not show a delete button if only stage is left', async () => {
const { queryByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
expect(queryByRole('button', { name: /delete stage/i })).not.toBeInTheDocument();
});
test('Show confirmation dialog when a stage was deleted', async () => {
const { user, getByRole, getAllByRole } = setup();
const { user, getByRole, getAllByRole, queryByText } = setup();
await waitFor(() => expect(queryByText('Workflow is loading')).not.toBeInTheDocument());
await user.click(
getByRole('button', {

View File

@ -0,0 +1,34 @@
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],
themeColorName,
name,
}));
}

View File

@ -19,6 +19,15 @@ export function getWorkflowValidationSchema({ formatMessage }) {
defaultMessage: 'Name can not be longer than 255 characters',
})
),
color: yup
.string()
.required(
formatMessage({
id: 'Settings.review-workflows.validation.stage.color',
defaultMessage: 'Color is required',
})
)
.matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i),
})
),
});

View File

@ -0,0 +1,27 @@
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),
themeColorName: expect.any(String),
name: expect.any(String),
});
});
});
test('getStageColorByHex()', () => {
expect(getStageColorByHex('#4945ff')).toStrictEqual({
name: 'Blue',
themeColorName: 'primary600',
});
expect(getStageColorByHex('random')).toStrictEqual(null);
expect(getStageColorByHex()).toStrictEqual(null);
});
});