mirror of
https://github.com/strapi/strapi.git
synced 2025-11-02 02:44:55 +00:00
Settings: Implement color select for stages
This commit is contained in:
parent
4b4d64bbb6
commit
ef3d7ff09a
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from './OptionColor';
|
||||
@ -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,
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from './SingleValueColor';
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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',
|
||||
},
|
||||
]),
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
@ -19,6 +19,15 @@ export function getWorkflowValidationSchema({ formatMessage }) {
|
||||
defaultMessage: 'Name can not be longer than 255 characters',
|
||||
})
|
||||
),
|
||||
color: yup
|
||||
.string()
|
||||
.required(
|
||||
formatMessage({
|
||||
id: 'Settings.review-workflows.validation.stage.color',
|
||||
defaultMessage: 'Color is required',
|
||||
})
|
||||
)
|
||||
.matches(/^#(?:[0-9a-fA-F]{3}){1,2}$/i),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user