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

View File

@ -18,6 +18,8 @@ import {
import { useIntl } from 'react-intl';
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 { getStageColorByHex } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/utils/colors';
import { STAGE_ATTRIBUTE_NAME } from '../../constants';
@ -25,7 +27,6 @@ import { STAGE_ATTRIBUTE_NAME } from '../../constants';
export function StageSelect() {
const {
initialData,
isCreatingEntry,
layout: { uid },
isSingleType,
onChange,
@ -34,17 +35,19 @@ export function StageSelect() {
const { formatMessage } = useIntl();
const { formatAPIError } = useAPIErrorHandler();
const toggleNotification = useNotification();
const { workflows, isLoading } = useReviewWorkflows();
const activeWorkflowStage = initialData?.[STAGE_ATTRIBUTE_NAME] ?? null;
// TODO: this works only as long as we support one workflow
const workflow = workflows?.[0] ?? null;
const {
meta,
workflows: [workflow],
isLoading,
} = useReviewWorkflows({ filters: { contentTypes: uid } });
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
// be updated at the same time when modifiedData is updated, otherwise
// the entity is flagged as modified
const currentWorkflowStage = initialData?.[STAGE_ATTRIBUTE_NAME] ?? null;
const activeWorkflowStage = initialData?.[STAGE_ATTRIBUTE_NAME] ?? null;
const mutation = useMutation(
async ({ entityId, stageId, uid }) => {
@ -71,36 +74,53 @@ export function StageSelect() {
type: 'success',
message: {
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 }) => {
mutation.mutate({
entityId: initialData.id,
stageId,
uid,
});
try {
/**
* If the current license has a limit:
* 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
@ -108,67 +128,107 @@ export function StageSelect() {
: {};
return (
<Field name={STAGE_ATTRIBUTE_NAME} id={STAGE_ATTRIBUTE_NAME}>
<Flex direction="column" gap={2} alignItems="stretch">
<SingleSelect
error={formattedError}
name={STAGE_ATTRIBUTE_NAME}
id={STAGE_ATTRIBUTE_NAME}
value={activeWorkflowStage?.id}
onChange={(value) => handleChange({ value })}
label={formatMessage({
id: 'content-manager.reviewWorkflows.stage.label',
defaultMessage: 'Review stage',
})}
startIcon={
<Flex
as="span"
height={2}
background={activeWorkflowStage?.color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius
shrink={0}
width={2}
marginRight="-3px"
/>
}
// eslint-disable-next-line react/no-unstable-nested-components
customizeContent={() => (
<Flex as="span" justifyContent="space-between" alignItems="center" width="100%">
<Typography textColor="neutral800" ellipsis>
{activeWorkflowStage?.name}
</Typography>
{isLoading ? <Loader small style={{ display: 'flex' }} /> : null}
</Flex>
)}
>
{workflow
? workflow.stages.map(({ id, color, name }) => {
const { themeColorName } = getStageColorByHex(color);
<>
<Field name={STAGE_ATTRIBUTE_NAME} id={STAGE_ATTRIBUTE_NAME}>
<Flex direction="column" gap={2} alignItems="stretch">
<SingleSelect
error={(mutation.error && formatAPIError(mutation.error)) || null}
name={STAGE_ATTRIBUTE_NAME}
id={STAGE_ATTRIBUTE_NAME}
value={activeWorkflowStage?.id}
onChange={(value) => handleChange({ value })}
label={formatMessage({
id: 'content-manager.reviewWorkflows.stage.label',
defaultMessage: 'Review stage',
})}
startIcon={
<Flex
as="span"
height={2}
background={activeWorkflowStage?.color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius
shrink={0}
width={2}
marginRight="-3px"
/>
}
// eslint-disable-next-line react/no-unstable-nested-components
customizeContent={() => (
<Flex as="span" justifyContent="space-between" alignItems="center" width="100%">
<Typography textColor="neutral800" ellipsis>
{activeWorkflowStage?.name}
</Typography>
{isLoading ? <Loader small style={{ display: 'flex' }} /> : null}
</Flex>
)}
>
{workflow
? workflow.stages.map(({ id, color, name }) => {
const { themeColorName } = getStageColorByHex(color);
return (
<SingleSelectOption
startIcon={
<Flex
height={2}
background={color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius
shrink={0}
width={2}
/>
}
value={id}
textValue={name}
>
{name}
</SingleSelectOption>
);
})
: []}
</SingleSelect>
<FieldError />
</Flex>
</Field>
return (
<SingleSelectOption
startIcon={
<Flex
height={2}
background={color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius
shrink={0}
width={2}
/>
}
value={id}
textValue={name}
>
{name}
</SingleSelectOption>
);
})
: []}
</SingleSelect>
<FieldError />
</Flex>
</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();
});
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', () => {
useCMEditViewDataManager.mockReturnValue({
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';

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);
});
});