mirror of
https://github.com/strapi/strapi.git
synced 2025-11-03 11:25:17 +00:00
feat(review-workflows): Fetch stages a user is allowed to transition into
This commit is contained in:
parent
4341b3db4c
commit
ec99de9ff9
@ -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 don’t 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>
|
||||
|
||||
@ -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 don’t have the permission to update this stage.')).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user