Fix: Apply multiple review workflow changes

This commit is contained in:
Gustav Hansen 2023-07-19 14:38:01 +02:00
parent 28aca28856
commit ebd3d25c50
5 changed files with 331 additions and 128 deletions

View File

@ -128,11 +128,15 @@ function ListView({
enabled: !!options?.reviewWorkflows, enabled: !!options?.reviewWorkflows,
} }
); );
const ReviewWorkflowsStage = useEnterprise( const ReviewWorkflowsColumns = useEnterprise(
REVIEW_WORKFLOW_COLUMNS_CELL_CE, REVIEW_WORKFLOW_COLUMNS_CELL_CE,
async () => async () => {
(await import('../../../../../ee/admin/content-manager/pages/ListView/ReviewWorkflowsColumn')) const { ReviewWorkflowsStageEE, ReviewWorkflowsAssigneeEE } = await import(
.ReviewWorkflowsStageEE, '../../../../../ee/admin/content-manager/pages/ListView/ReviewWorkflowsColumn'
);
return { ReviewWorkflowsStageEE, ReviewWorkflowsAssigneeEE };
},
{ {
enabled: hasReviewWorkflows, enabled: hasReviewWorkflows,
} }
@ -457,7 +461,7 @@ function ListView({
}; };
// Block rendering until the review stage component is fully loaded in EE // Block rendering until the review stage component is fully loaded in EE
if (!ReviewWorkflowsStage) { if (!ReviewWorkflowsColumns) {
return null; return null;
} }
@ -607,21 +611,38 @@ function ListView({
); );
} }
if (hasReviewWorkflows && name === 'strapi_stage') { if (hasReviewWorkflows) {
return ( if (name === 'strapi_stage') {
<Td key={key}> return (
{rowData.strapi_stage ? ( <Td key={key}>
<ReviewWorkflowsStage {rowData.strapi_stage ? (
color={ <ReviewWorkflowsColumns.ReviewWorkflowsStageEE
rowData.strapi_stage.color ?? lightTheme.colors.primary600 color={
} rowData.strapi_stage.color ?? lightTheme.colors.primary600
name={rowData.strapi_stage.name} }
/> name={rowData.strapi_stage.name}
) : ( />
<Typography textColor="neutral800">-</Typography> ) : (
)} <Typography textColor="neutral800">-</Typography>
</Td> )}
); </Td>
);
}
if (name === 'strapi_assignee') {
return (
<Td key={key}>
{rowData.strapi_assignee ? (
<ReviewWorkflowsColumns.ReviewWorkflowsAssigneeEE
firstname={rowData.strapi_assignee.firstname}
lastname={rowData?.strapi_assignee?.lastname}
displayname={rowData?.strapi_assignee?.username}
/>
) : (
<Typography textColor="neutral800">-</Typography>
)}
</Td>
);
}
} }
return ( return (

View File

@ -18,6 +18,8 @@ import {
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useMutation } from 'react-query'; import { useMutation } from 'react-query';
import { useLicenseLimits } from '../../../../../../hooks/useLicenseLimits';
import * as LimitsModal from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/components/LimitsModal';
import { useReviewWorkflows } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows'; import { useReviewWorkflows } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows';
import { getStageColorByHex } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/utils/colors'; import { getStageColorByHex } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/utils/colors';
import { STAGE_ATTRIBUTE_NAME } from '../../constants'; import { STAGE_ATTRIBUTE_NAME } from '../../constants';
@ -25,7 +27,6 @@ import { STAGE_ATTRIBUTE_NAME } from '../../constants';
export function StageSelect() { export function StageSelect() {
const { const {
initialData, initialData,
isCreatingEntry,
layout: { uid }, layout: { uid },
isSingleType, isSingleType,
onChange, onChange,
@ -34,17 +35,19 @@ export function StageSelect() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { formatAPIError } = useAPIErrorHandler(); const { formatAPIError } = useAPIErrorHandler();
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const { workflows, isLoading } = useReviewWorkflows(); const {
meta,
const activeWorkflowStage = initialData?.[STAGE_ATTRIBUTE_NAME] ?? null; workflows: [workflow],
isLoading,
// TODO: this works only as long as we support one workflow } = useReviewWorkflows({ filters: { contentTypes: uid } });
const workflow = workflows?.[0] ?? null; const { getFeature } = useLicenseLimits();
const [showLimitModal, setShowLimitModal] = React.useState(false);
const limits = getFeature('review-workflows');
// it is possible to rely on initialData here, because it always will // it is possible to rely on initialData here, because it always will
// be updated at the same time when modifiedData is updated, otherwise // be updated at the same time when modifiedData is updated, otherwise
// the entity is flagged as modified // the entity is flagged as modified
const currentWorkflowStage = initialData?.[STAGE_ATTRIBUTE_NAME] ?? null; const activeWorkflowStage = initialData?.[STAGE_ATTRIBUTE_NAME] ?? null;
const mutation = useMutation( const mutation = useMutation(
async ({ entityId, stageId, uid }) => { async ({ entityId, stageId, uid }) => {
@ -71,36 +74,53 @@ export function StageSelect() {
type: 'success', type: 'success',
message: { message: {
id: 'content-manager.reviewWorkflows.stage.notification.saved', id: 'content-manager.reviewWorkflows.stage.notification.saved',
defaultMessage: 'Success: Review stage updated', defaultMessage: 'Review stage updated',
}, },
}); });
}, },
} }
); );
// if entities are created e.g. through lifecycle methods
// they may not have a stage assigned. Updating the entity won't
// set the default stage either which may lead to entities that
// do not have a stage assigned for a while. By displaying an
// error by default we are trying to nudge users into assigning a stage.
const initialStageNullError =
currentWorkflowStage === null &&
!isLoading &&
!isCreatingEntry &&
formatMessage({
id: 'content-manager.reviewWorkflows.stage.select.placeholder',
defaultMessage: 'Select a stage',
});
const formattedError =
(mutation.error && formatAPIError(mutation.error)) || initialStageNullError || null;
const handleChange = async ({ value: stageId }) => { const handleChange = async ({ value: stageId }) => {
mutation.mutate({ try {
entityId: initialData.id, /**
stageId, * If the current license has a limit:
uid, * check if the total count of workflows exceeds that limit and display
}); * the limits modal.
*
* If the current license does not have a limit (e.g. offline license):
* do nothing (for now).
*
*/
if (limits?.workflows && parseInt(limits.workflows, 10) > meta.workflowCount) {
setShowLimitModal('workflow');
/**
* If the current license has a limit:
* check if the total count of stages exceeds that limit and display
* the limits modal.
*
* If the current license does not have a limit (e.g. offline license):
* do nothing (for now).
*
*/
} else if (
limits?.stagesPerWorkflow &&
parseInt(limits.stagesPerWorkflow, 10) > workflow.stages.length
) {
setShowLimitModal('stage');
} else {
mutation.mutateAsync({
entityId: initialData.id,
stageId,
uid,
});
}
} catch (error) {
// react-query@v3: the error doesn't have to be handled here
// see: https://github.com/TanStack/query/issues/121
}
}; };
const { themeColorName } = activeWorkflowStage?.color const { themeColorName } = activeWorkflowStage?.color
@ -108,67 +128,107 @@ export function StageSelect() {
: {}; : {};
return ( return (
<Field name={STAGE_ATTRIBUTE_NAME} id={STAGE_ATTRIBUTE_NAME}> <>
<Flex direction="column" gap={2} alignItems="stretch"> <Field name={STAGE_ATTRIBUTE_NAME} id={STAGE_ATTRIBUTE_NAME}>
<SingleSelect <Flex direction="column" gap={2} alignItems="stretch">
error={formattedError} <SingleSelect
name={STAGE_ATTRIBUTE_NAME} error={(mutation.error && formatAPIError(mutation.error)) || null}
id={STAGE_ATTRIBUTE_NAME} name={STAGE_ATTRIBUTE_NAME}
value={activeWorkflowStage?.id} id={STAGE_ATTRIBUTE_NAME}
onChange={(value) => handleChange({ value })} value={activeWorkflowStage?.id}
label={formatMessage({ onChange={(value) => handleChange({ value })}
id: 'content-manager.reviewWorkflows.stage.label', label={formatMessage({
defaultMessage: 'Review stage', id: 'content-manager.reviewWorkflows.stage.label',
})} defaultMessage: 'Review stage',
startIcon={ })}
<Flex startIcon={
as="span" <Flex
height={2} as="span"
background={activeWorkflowStage?.color} height={2}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'} background={activeWorkflowStage?.color}
hasRadius borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
shrink={0} hasRadius
width={2} shrink={0}
marginRight="-3px" width={2}
/> marginRight="-3px"
} />
// eslint-disable-next-line react/no-unstable-nested-components }
customizeContent={() => ( // eslint-disable-next-line react/no-unstable-nested-components
<Flex as="span" justifyContent="space-between" alignItems="center" width="100%"> customizeContent={() => (
<Typography textColor="neutral800" ellipsis> <Flex as="span" justifyContent="space-between" alignItems="center" width="100%">
{activeWorkflowStage?.name} <Typography textColor="neutral800" ellipsis>
</Typography> {activeWorkflowStage?.name}
{isLoading ? <Loader small style={{ display: 'flex' }} /> : null} </Typography>
</Flex> {isLoading ? <Loader small style={{ display: 'flex' }} /> : null}
)} </Flex>
> )}
{workflow >
? workflow.stages.map(({ id, color, name }) => { {workflow
const { themeColorName } = getStageColorByHex(color); ? workflow.stages.map(({ id, color, name }) => {
const { themeColorName } = getStageColorByHex(color);
return ( return (
<SingleSelectOption <SingleSelectOption
startIcon={ startIcon={
<Flex <Flex
height={2} height={2}
background={color} background={color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'} borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius hasRadius
shrink={0} shrink={0}
width={2} width={2}
/> />
} }
value={id} value={id}
textValue={name} textValue={name}
> >
{name} {name}
</SingleSelectOption> </SingleSelectOption>
); );
}) })
: []} : []}
</SingleSelect> </SingleSelect>
<FieldError /> <FieldError />
</Flex> </Flex>
</Field> </Field>
<LimitsModal.Root
isOpen={showLimitModal === 'workflow'}
onClose={() => setShowLimitModal(false)}
>
<LimitsModal.Title>
{formatMessage({
id: 'content-manager.reviewWorkflows.workflows.limit.title',
defaultMessage: 'Youve reached the limit of workflows in your plan',
})}
</LimitsModal.Title>
<LimitsModal.Body>
{formatMessage({
id: 'content-manager.reviewWorkflows.workflows.limit.body',
defaultMessage: 'Delete a workflow or contact Sales to enable more workflows.',
})}
</LimitsModal.Body>
</LimitsModal.Root>
<LimitsModal.Root
isOpen={showLimitModal === 'stage'}
onClose={() => setShowLimitModal(false)}
>
<LimitsModal.Title>
{formatMessage({
id: 'content-manager.reviewWorkflows.stages.limit.title',
defaultMessage: 'You have reached the limit of stages for this workflow in your plan',
})}
</LimitsModal.Title>
<LimitsModal.Body>
{formatMessage({
id: 'content-manager.reviewWorkflows.stages.limit.body',
defaultMessage: 'Try deleting some stages or contact Sales to enable more stages.',
})}
</LimitsModal.Body>
</LimitsModal.Root>
</>
); );
} }

View File

@ -92,21 +92,6 @@ describe('EE | Content Manager | EditView | InformationBox | StageSelect', () =>
server.close(); server.close();
}); });
it('renders an error, if no workflow stage is assigned to the entity', async () => {
useCMEditViewDataManager.mockReturnValue({
initialData: {
[STAGE_ATTRIBUTE_NAME]: null,
},
layout: { uid: 'api::articles:articles' },
});
const { getByText, queryByRole } = setup();
expect(queryByRole('combobox')).toBeInTheDocument();
await waitFor(() => expect(getByText(/select a stage/i)).toBeInTheDocument());
});
it('renders an enabled select input, if the entity is edited', () => { it('renders an enabled select input, if the entity is edited', () => {
useCMEditViewDataManager.mockReturnValue({ useCMEditViewDataManager.mockReturnValue({
initialData: { initialData: {

View File

@ -1,2 +1,2 @@
export const STAGE_ATTRIBUTE_NAME = 'strapi_reviewWorkflows_stage'; export const STAGE_ATTRIBUTE_NAME = 'strapi_stage';
export const ASSIGNEE_ATTRIBUTE_NAME = 'strapi_assignee'; export const ASSIGNEE_ATTRIBUTE_NAME = 'strapi_assignee';

View File

@ -0,0 +1,137 @@
import React from 'react';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import { render } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { STAGE_ATTRIBUTE_NAME, ASSIGNEE_ATTRIBUTE_NAME } from '../constants';
import { InformationBoxEE } from '../InformationBoxEE';
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
useCMEditViewDataManager: jest.fn(),
useNotification: jest.fn(() => ({
toggleNotification: jest.fn(),
})),
}));
const server = setupServer(
rest.get('*/users', (req, res, ctx) =>
res(
ctx.json({
data: {
results: [
{
id: 1,
},
],
pagination: {
page: 1,
},
},
})
)
),
rest.get('*/review-workflows/workflows', (req, res, ctx) =>
res(
ctx.json({
data: [
{
id: 1,
},
],
})
)
)
);
const setup = (props) => {
return render(<InformationBoxEE {...props} />, {
wrapper({ children }) {
const store = createStore((state = {}) => state, {});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en" defaultLocale="en">
<ThemeProvider theme={lightTheme}>{children}</ThemeProvider>
</IntlProvider>
</QueryClientProvider>
</Provider>
);
},
});
};
describe('EE | Content Manager | EditView | InformationBox', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
it('renders the title and body of the Information component', () => {
useCMEditViewDataManager.mockReturnValue({
initialData: {},
isCreatingEntry: true,
layout: { uid: 'api::articles:articles' },
});
const { getByText } = setup();
expect(getByText('Information')).toBeInTheDocument();
expect(getByText('Last update')).toBeInTheDocument();
});
it('renders neither stage nor assignee select inputs, if no nothing is returned for an entity', () => {
useCMEditViewDataManager.mockReturnValue({
initialData: {},
layout: { uid: 'api::articles:articles' },
});
const { queryByRole } = setup();
expect(queryByRole('combobox')).not.toBeInTheDocument();
});
it('renders stage and assignee select inputs, if it is returned for an entity', () => {
useCMEditViewDataManager.mockReturnValue({
initialData: {
[STAGE_ATTRIBUTE_NAME]: {
id: 1,
color: '#4945FF',
name: 'Stage 1',
worklow: 1,
},
[ASSIGNEE_ATTRIBUTE_NAME]: {
id: 1,
firstname: 'Firstname',
lastname: 'Lastname',
},
},
layout: { uid: 'api::articles:articles' },
});
const { queryAllByRole } = setup();
expect(queryAllByRole('combobox').length).toBe(2);
});
});