feat(review-workflows): Fetch stages a user is allowed to transition into

This commit is contained in:
Gustav Hansen 2023-08-03 16:35:35 +02:00
parent 4341b3db4c
commit ec99de9ff9
5 changed files with 400 additions and 111 deletions

View File

@ -5,6 +5,7 @@ import {
SingleSelectOption,
Field,
FieldError,
FieldHint,
Flex,
Loader,
Typography,
@ -24,26 +25,22 @@ import {
CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME,
CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME,
} from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/constants';
import { useReviewWorkflows } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows';
import { useReviewWorkflowsStages } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflowsStages';
import { getStageColorByHex } from '../../../../../../pages/SettingsPage/pages/ReviewWorkflows/utils/colors';
import { STAGE_ATTRIBUTE_NAME } from '../../constants';
export function StageSelect() {
const {
initialData,
layout: { uid },
isSingleType,
onChange,
} = useCMEditViewDataManager();
const { initialData, layout: contentType, isSingleType, onChange } = useCMEditViewDataManager();
const { put } = useFetchClient();
const { formatMessage } = useIntl();
const { formatAPIError } = useAPIErrorHandler();
const toggleNotification = useNotification();
const {
meta,
workflows: [workflow],
isLoading,
} = useReviewWorkflows({ filters: { contentTypes: uid } });
const { meta, stages, isLoading } = useReviewWorkflowsStages(
{ id: initialData.id, layout: contentType },
{
enabled: !!initialData?.id,
}
);
const { getFeature } = useLicenseLimits();
const [showLimitModal, setShowLimitModal] = React.useState(false);
@ -114,15 +111,14 @@ export function StageSelect() {
*/
} else if (
limits?.[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME] &&
parseInt(limits[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME], 10) <
workflow.stages.length
parseInt(limits[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME], 10) < stages.length
) {
setShowLimitModal('stage');
} else {
mutation.mutateAsync({
entityId: initialData.id,
stageId,
uid,
uid: contentType.uid,
});
}
} catch (error) {
@ -137,9 +133,20 @@ export function StageSelect() {
return (
<>
<Field name={STAGE_ATTRIBUTE_NAME} id={STAGE_ATTRIBUTE_NAME}>
<Field
hint={
stages.length === 0 &&
formatMessage({
id: 'content-manager.reviewWorkflows.stages.no-transition',
defaultMessage: 'You dont have the permission to update this stage.',
})
}
name={STAGE_ATTRIBUTE_NAME}
id={STAGE_ATTRIBUTE_NAME}
>
<Flex direction="column" gap={2} alignItems="stretch">
<SingleSelect
disabled={stages.length === 0}
error={(mutation.error && formatAPIError(mutation.error)) || null}
name={STAGE_ATTRIBUTE_NAME}
id={STAGE_ATTRIBUTE_NAME}
@ -150,52 +157,56 @@ export function StageSelect() {
defaultMessage: 'Review stage',
})}
startIcon={
<Flex
as="span"
height={2}
background={activeWorkflowStage?.color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius
shrink={0}
width={2}
marginRight="-3px"
/>
activeWorkflowStage && (
<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}
{activeWorkflowStage?.name ?? ''}
</Typography>
{isLoading ? <Loader small style={{ display: 'flex' }} /> : null}
{isLoading ? (
<Loader small style={{ display: 'flex' }} data-testid="loader" />
) : null}
</Flex>
)}
>
{workflow
? workflow.stages.map(({ id, color, name }) => {
const { themeColorName } = getStageColorByHex(color);
{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>
);
})
: []}
return (
<SingleSelectOption
key={id}
startIcon={
<Flex
height={2}
background={color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius
shrink={0}
width={2}
/>
}
value={id}
textValue={name}
>
{name}
</SingleSelectOption>
);
})}
</SingleSelect>
<FieldHint />
<FieldError />
</Flex>
</Field>

View File

@ -1,8 +1,7 @@
import React from 'react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import { render, waitFor } from '@testing-library/react';
import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
@ -11,45 +10,49 @@ import { QueryClientProvider, QueryClient } from 'react-query';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { STAGE_ATTRIBUTE_NAME } from '../../../constants';
import { StageSelect } from '../StageSelect';
const STAGE_1_STATE_FIXTURE = {
id: 1,
color: '#4945FF',
name: 'Stage 1',
worklow: 1,
};
const server = setupServer(
rest.get('*/review-workflows/workflows/', (req, res, ctx) =>
res(
ctx.json({
data: [
{
id: 1,
stages: [
{
id: 1,
color: '#4945FF',
name: 'Stage 1',
},
{
id: 2,
color: '#4945FF',
name: 'Stage 2',
},
],
},
],
})
)
)
...[
rest.get('*/content-manager/:kind/:uid/:id/stages', (req, res, ctx) =>
res(
ctx.json({
data: [
{
id: 1,
color: '#4945FF',
name: 'Stage 1',
},
{
id: 2,
color: '#4945FF',
name: 'Stage 2',
},
],
})
)
),
rest.get('*/license-limit-information', (req, res, ctx) => res(ctx.json({}))),
]
);
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
useCMEditViewDataManager: jest.fn(),
useCMEditViewDataManager: jest.fn().mockReturnValue({
initialData: {
id: 1,
strapi_stage: {
id: 1,
color: '#4945FF',
name: 'Stage 1',
},
},
isCreatingEntry: false,
isSingleType: false,
layout: { uid: 'api::articles:articles' },
}),
useNotification: jest.fn(() => ({
toggleNotification: jest.fn(),
})),
@ -92,40 +95,36 @@ describe('EE | Content Manager | EditView | InformationBox | StageSelect', () =>
server.close();
});
it('renders an enabled select input, if the entity is edited', () => {
useCMEditViewDataManager.mockReturnValue({
initialData: {
[STAGE_ATTRIBUTE_NAME]: null,
},
isCreatingEntry: false,
layout: { uid: 'api::articles:articles' },
});
const { queryByRole } = setup();
const select = queryByRole('combobox');
expect(select).toBeInTheDocument();
afterEach(() => {
jest.clearAllMocks();
});
it('renders a select input, if a workflow stage is assigned to the entity', async () => {
useCMEditViewDataManager.mockReturnValue({
initialData: {
[STAGE_ATTRIBUTE_NAME]: STAGE_1_STATE_FIXTURE,
},
isCreatingEntry: false,
layout: { uid: 'api::articles:articles' },
});
const { queryByRole, getByTestId, getByText, user } = setup();
const { queryByRole, queryByTestId, getByText, user } = setup();
await waitForElementToBeRemoved(() => getByTestId('loader'));
await waitFor(() => expect(queryByTestId('loader')).not.toBeInTheDocument());
await waitFor(() => expect(getByText('Stage 1')).toBeInTheDocument());
const select = queryByRole('combobox');
await user.click(queryByRole('combobox'));
expect(getByText('Stage 1')).toBeInTheDocument();
await waitFor(() => expect(getByText('Stage 2')).toBeInTheDocument());
});
await user.click(select);
it("renders the select as disabled with a hint, if there aren't any stages", async () => {
server.use(
rest.get('*/content-manager/:kind/:uid/:id/stages', (req, res, ctx) => {
return res.once(ctx.json({ data: [] }));
})
);
expect(getByText('Stage 2')).toBeInTheDocument();
const { queryByRole, getByText, getByTestId } = setup();
await waitForElementToBeRemoved(() => getByTestId('loader'));
await waitFor(() => expect(queryByRole('combobox')).toHaveAttribute('aria-disabled', 'true'));
await waitFor(() =>
expect(getByText('You dont have the permission to update this stage.')).toBeInTheDocument()
);
});
});

View File

@ -0,0 +1,108 @@
import * as React from 'react';
import { renderHook, waitFor } 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 { useReviewWorkflows } from '../useReviewWorkflows';
const server = setupServer(
rest.get(
'*/content-manager/collection-types/api::collection.collection/stages',
(req, res, ctx) =>
res(
ctx.json({
data: [
{
id: 1,
name: 'Todo',
},
{
id: 2,
name: 'Done',
},
],
meta: {
workflowCount: 10,
stagesCount: 5,
},
})
)
),
rest.get('*/content-manager/single-types/api::single.single/stages', (req, res, ctx) =>
res(
ctx.json({
data: [
{
id: 2,
name: 'Todo',
},
{
id: 3,
name: 'Done',
},
],
meta: {
workflowCount: 10,
stagesCount: 5,
},
})
)
)
);
const setup = (...args) =>
renderHook(() => useReviewWorkflows(...args), {
wrapper({ children }) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return (
<QueryClientProvider client={client}>
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
</QueryClientProvider>
);
},
});
describe('useReviewWorkflows', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
test('fetches many workflows', async () => {
const { result } = setup();
await waitFor(() => result.current.isLoading === false);
});
test('fetches one workflow', async () => {
const { result } = setup({ id: 1 });
await waitFor(() => result.current.isLoading === false);
});
test('forwards all params except "id" as query params', async () => {
const { result } = setup({ id: 1 });
await waitFor(() => result.current.isLoading === false);
});
});

View File

@ -0,0 +1,136 @@
import * as React from 'react';
import { renderHook, waitFor } 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 { useReviewWorkflowsStages } from '../useReviewWorkflowsStages';
const server = setupServer(
...[
rest.get('*/content-manager/collection-types/:uid/:id/stages', (req, res, ctx) =>
res(
ctx.json({
data: [
{
id: 1,
name: 'Default',
},
],
meta: {
workflowCount: 10,
},
})
)
),
rest.get('*/content-manager/single-types/:uid/:id/stages', (req, res, ctx) =>
res(
ctx.json({
data: [
{
id: 1,
name: 'Default',
},
],
meta: {
workflowCount: 10,
},
})
)
),
]
);
const setup = (...args) =>
renderHook(() => useReviewWorkflowsStages(...args), {
wrapper({ children }) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return (
<QueryClientProvider client={client}>
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
</QueryClientProvider>
);
},
});
describe('useReviewWorkflowsStages', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
test('fetches stages for collection-types', async () => {
const { result } = setup({
id: 1,
layout: {
uid: 'api::collection.collection',
kind: 'collectionType',
},
});
await waitFor(() => expect(result.current.stages).toStrictEqual([]));
await waitFor(() => expect(result.current.meta).toStrictEqual({}));
await waitFor(() => result.current.isLoading === false);
expect(result.current.stages).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
id: 1,
}),
])
);
expect(result.current.meta).toStrictEqual(
expect.objectContaining({
workflowCount: expect.any(Number),
})
);
});
test('fetches stages for single-types', async () => {
const { result } = setup({
id: 1,
layout: {
uid: 'api::single.single',
kind: 'singleType',
},
});
await waitFor(() => expect(result.current.stages).toStrictEqual([]));
await waitFor(() => expect(result.current.meta).toStrictEqual({}));
await waitFor(() => result.current.isLoading === false);
expect(result.current.stages).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
id: 1,
}),
])
);
expect(result.current.meta).toStrictEqual(
expect.objectContaining({
workflowCount: expect.any(Number),
})
);
});
});

View File

@ -0,0 +1,35 @@
import * as React from 'react';
import { useFetchClient } from '@strapi/helper-plugin';
import { useQuery } from 'react-query';
export function useReviewWorkflowsStages({ id, layout } = {}, queryOptions = {}) {
const { kind, uid } = layout;
const slug = kind === 'collectionType' ? 'collection-types' : 'single-types';
const { get } = useFetchClient();
const { data, isLoading } = useQuery(
['content-manager', slug, layout.uid, id, 'stages'],
async () => {
const { data } = await get(`/admin/content-manager/${slug}/${uid}/${id}/stages`);
return data;
},
queryOptions
);
// these return values need to be memoized, because the default value
// would lead to infinite rendering loops when used in a dependency array
// on an effect
const meta = React.useMemo(() => data?.meta ?? {}, [data?.meta]);
const stages = React.useMemo(() => data?.data ?? [], [data?.data]);
return {
// meta contains e.g. the total of all workflows. we can not use
// the pagination object here, because the list is not paginated.
meta,
stages,
isLoading,
};
}