Merge pull request #17101 from strapi/feature/review-workflow-multiple-workflow-limits

This commit is contained in:
Josh 2023-06-29 14:43:56 +01:00 committed by GitHub
commit 4e45c46dc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 506 additions and 29 deletions

View File

@ -11,6 +11,8 @@ import { useIntl } from 'react-intl';
import { useMutation } from 'react-query';
import Information from '../../../../../../admin/src/content-manager/pages/EditView/Information';
import * as LimitsModal from '../../../../pages/SettingsPage/pages/ReviewWorkflows/components/LimitsModal';
import { useReviewWorkflowLicenseLimits } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflowLicenseLimits';
import { useReviewWorkflows } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows';
import { getStageColorByHex } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/utils/colors';
@ -33,8 +35,11 @@ export function InformationBoxEE() {
const { formatMessage } = useIntl();
const { formatAPIError } = useAPIErrorHandler();
const toggleNotification = useNotification();
const { limits } = useReviewWorkflowLicenseLimits();
const [showLimitModal, setShowLimitModal] = React.useState(false);
const {
pagination,
workflows: [workflow],
isLoading: isWorkflowLoading,
} = useReviewWorkflows({ filters: { contentTypes: uid } });
@ -72,6 +77,17 @@ export function InformationBoxEE() {
const handleStageChange = async ({ value: stageId }) => {
try {
if (limits?.workflows > pagination.total) {
setShowLimitModal('workflow');
return;
}
if (limits?.stagesPerWorkflow > workflow.stages.length) {
setShowLimitModal('stage');
return;
}
await mutateAsync({
entityId: initialData.id,
stageId,
@ -152,6 +168,44 @@ export function InformationBoxEE() {
)}
<Information.Body />
<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>
</Information.Root>
);
}

View File

@ -1,7 +1,8 @@
import React from 'react';
import * as React from 'react';
import { fixtures } from '@strapi/admin-test-utils';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import { useCMEditViewDataManager, RBACContext } from '@strapi/helper-plugin';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
@ -66,13 +67,27 @@ const ComponentFixture = (props) => <InformationBoxEE {...props} />;
const setup = (props) => ({
...render(<ComponentFixture {...props} />, {
wrapper({ children }) {
const store = createStore((state = {}) => state, {});
const store = createStore((state = {}) => state, {
admin_app: {
permissions: fixtures.permissions.app,
},
});
// eslint-disable-next-line react-hooks/rules-of-hooks
const rbacContextValue = React.useMemo(
() => ({
allPermissions: fixtures.permissions.allPermissions,
}),
[]
);
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en" defaultLocale="en">
<ThemeProvider theme={lightTheme}>{children}</ThemeProvider>
<ThemeProvider theme={lightTheme}>
<RBACContext.Provider value={rbacContextValue}>{children}</RBACContext.Provider>
</ThemeProvider>
</IntlProvider>
</QueryClientProvider>
</Provider>

View File

@ -6,27 +6,27 @@ import { selectAdminPermissions } from '../../../../admin/src/pages/App/selector
const useLicenseLimits = () => {
const permissions = useSelector(selectAdminPermissions);
const rbac = useRBAC(permissions.settings.users);
const { get } = useFetchClient();
const {
isLoading: isRBACLoading,
allowedActions: { canRead, canCreate, canUpdate, canDelete },
} = rbac;
} = useRBAC(permissions.settings.users);
const isRBACAllowed = canRead && canCreate && canUpdate && canDelete;
const { get } = useFetchClient();
const fetchLicenseLimitInfo = async () => {
const license = useQuery(
['ee', 'license-limit-info'],
async () => {
const {
data: { data },
} = await get('/admin/license-limit-information');
return data;
};
const license = useQuery(['ee', 'license-limit-info'], fetchLicenseLimitInfo, {
},
{
enabled: !isRBACLoading && isRBACAllowed,
});
}
);
return { license };
};

View File

@ -0,0 +1,111 @@
import * as React from 'react';
import { Box, Flex, IconButton, ModalLayout, ModalBody, Typography } from '@strapi/design-system';
import { LinkButton } from '@strapi/design-system/v2';
import { Cross } from '@strapi/icons';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import balloonImageSrc from './assets/balloon.png';
const TITLE_ID = 'limits-title';
const CTA_LEARN_MORE_HREF = 'https://strapi.io/pricing-cloud';
const CTA_SALES_HREF = 'https://strapi.io/contact-sales';
export function Title({ children }) {
return (
<Typography variant="alpha" id={TITLE_ID}>
{children}
</Typography>
);
}
Title.propTypes = {
children: PropTypes.node.isRequired,
};
export function Body({ children }) {
return <Typography variant="omega">{children}</Typography>;
}
Body.propTypes = {
children: PropTypes.node.isRequired,
};
function CallToActions() {
const { formatMessage } = useIntl();
return (
<Flex gap={2} paddingTop={4}>
<LinkButton variant="default" isExternal href={CTA_LEARN_MORE_HREF}>
{formatMessage({
id: 'Settings.review-workflows.limit.cta.learn',
defaultMessage: 'Learn more',
})}
</LinkButton>
<LinkButton variant="tertiary" isExternal href={CTA_SALES_HREF}>
{formatMessage({
id: 'Settings.review-workflows.limit.cta.sales',
defaultMessage: 'Contact Sales',
})}
</LinkButton>
</Flex>
);
}
const BalloonImage = styled.img`
// Margin top|right reverse the padding of ModalBody
margin-right: ${({ theme }) => `-${theme.spaces[7]}`};
margin-top: ${({ theme }) => `-${theme.spaces[7]}`};
width: 360px;
`;
export function LimitsModal({ children, isOpen, onClose }) {
const { formatMessage } = useIntl();
if (!isOpen) {
return null;
}
return (
<ModalLayout labelledBy={TITLE_ID}>
<ModalBody>
<Flex gap={2} paddingLeft={7} position="relative">
<Flex alignItems="start" direction="column" gap={2} width="60%">
{children}
<CallToActions />
</Flex>
<Flex justifyContent="end" height="100%" width="40%">
<BalloonImage src={balloonImageSrc} aria-hidden alt="" loading="lazy" />
<Box display="flex" position="absolute" right={0} top={0}>
<IconButton
icon={<Cross />}
aria-label={formatMessage({
id: 'global.close',
defaultMessage: 'Close',
})}
onClick={onClose}
/>
</Box>
</Flex>
</Flex>
</ModalBody>
</ModalLayout>
);
}
LimitsModal.defaultProps = {
isOpen: false,
};
LimitsModal.propTypes = {
children: PropTypes.node.isRequired,
isOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -0,0 +1,3 @@
import { Title, Body, LimitsModal as Root } from './LimitsModal';
export { Title, Body, Root };

View File

@ -0,0 +1,62 @@
import React from 'react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
import * as LimitsModal from '..';
const setup = (props) => ({
...render(
<LimitsModal.Root isOpen onClose={() => {}} {...props}>
<LimitsModal.Title>Title</LimitsModal.Title>
<LimitsModal.Body>Body</LimitsModal.Body>
</LimitsModal.Root>,
{
wrapper({ children }) {
return (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
</ThemeProvider>
);
},
}
),
user: userEvent.setup(),
});
describe('Admin | Settings | Review Workflow | LimitsModal', () => {
it('should not render the modal if isOpen=false', () => {
const { queryByText } = setup({ isOpen: false });
expect(queryByText('Title')).not.toBeInTheDocument();
expect(queryByText('Body')).not.toBeInTheDocument();
});
it('should render the modal if isOpen=true', () => {
const { getByText } = setup();
expect(getByText('Title')).toBeInTheDocument();
expect(getByText('Body')).toBeInTheDocument();
});
it('should render call to action links', () => {
const { getByText } = setup();
expect(getByText('Learn more')).toBeInTheDocument();
expect(getByText('Contact Sales')).toBeInTheDocument();
});
it('should call onClose callback when closing the modal', async () => {
const onCloseSpy = jest.fn();
const { getByRole, user } = setup({ onClose: onCloseSpy });
await user.click(getByRole('button', { name: /close/i }));
expect(onCloseSpy).toBeCalledTimes(1);
});
});

View File

@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react';
import { useReviewWorkflowLicenseLimits } from '../useReviewWorkflowLicenseLimits';
// TODO: use msw instead. I wish I could have done it already, but in its current
// state, useLicenseLimits requires to be wrapped in a redux provider and RBAC
// context provider and at the time of writing there wasn't any time to create
// that setup.
jest.mock('../../../../../../hooks', () => ({
...jest.requireActual('../../../../../../hooks'),
useLicenseLimits: jest.fn(() => ({
isLoading: false,
license: {
data: {
something: true,
features: [
{
name: 'review-workflows',
options: {
workflows: 10,
stagesPerWorkflow: 10,
},
},
],
},
},
})),
}));
function setup(...args) {
return renderHook(() => useReviewWorkflowLicenseLimits(...args));
}
describe('useReviewWorkflowLicenseLimits', () => {
it('returns options for the feature only', async () => {
const { result } = setup();
expect(result.current.limits).toStrictEqual({
workflows: 10,
stagesPerWorkflow: 10,
});
});
});

View File

@ -25,6 +25,13 @@ const server = setupServer(
stages: populate === 'stages' ? [STAGE_FIXTURE] : [],
},
],
pagination: {
page: 1,
pageSize: 100,
pageCount: 1,
total: 1,
},
})
);
}),
@ -38,6 +45,13 @@ const server = setupServer(
id: 1,
stages: populate === 'stages' ? [STAGE_FIXTURE] : [],
},
pagination: {
page: 1,
pageSize: 100,
pageCount: 1,
total: 1,
},
})
);
})
@ -80,6 +94,7 @@ describe('useReviewWorkflows', () => {
expect(result.current).toStrictEqual(
expect.objectContaining({
status: 'success',
pagination: expect.objectContaining({ total: 1 }),
workflows: [{ id: expect.any(Number), stages: expect.any(Array) }],
})
);
@ -92,6 +107,7 @@ describe('useReviewWorkflows', () => {
expect(result.current).toStrictEqual(
expect.objectContaining({
pagination: expect.objectContaining({ total: 1 }),
workflows: [expect.objectContaining({ id: 1, stages: expect.any(Array) })],
})
);

View File

@ -0,0 +1,12 @@
import { useLicenseLimits } from '../../../../../hooks';
export function useReviewWorkflowLicenseLimits() {
const { license, isLoading } = useLicenseLimits();
const limits =
license?.data?.features?.filter(({ name }) => name === 'review-workflows')?.[0]?.options ?? {};
return {
limits,
isLoading,
};
}

View File

@ -15,9 +15,7 @@ export function useReviewWorkflows(params = {}) {
['review-workflows', 'workflows', id],
async () => {
try {
const {
data: { data },
} = await get(
const { data } = await get(
`/admin/review-workflows/workflows/${id}${queryString ? `?${queryString}` : ''}`
);
@ -31,13 +29,14 @@ export function useReviewWorkflows(params = {}) {
let workflows = [];
if (id && data) {
workflows = [data];
} else if (Array.isArray(data)) {
workflows = data;
if (id && data?.data) {
workflows = [data.data];
} else if (Array.isArray(data?.data)) {
workflows = data.data;
}
return {
pagination: data?.pagination ?? {},
workflows,
isLoading,
status,

View File

@ -13,9 +13,12 @@ import { useContentTypes } from '../../../../../../../../admin/src/hooks/useCont
import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer';
import { resetWorkflow } from '../../actions';
import * as Layout from '../../components/Layout';
import * as LimitsModal from '../../components/LimitsModal';
import { Stages } from '../../components/Stages';
import { WorkflowAttributes } from '../../components/WorkflowAttributes';
import { REDUX_NAMESPACE } from '../../constants';
import { useReviewWorkflowLicenseLimits } from '../../hooks/useReviewWorkflowLicenseLimits';
import { useReviewWorkflows } from '../../hooks/useReviewWorkflows';
import { reducer, initialState } from '../../reducer';
import { getWorkflowValidationSchema } from '../../utils/getWorkflowValidationSchema';
@ -32,6 +35,9 @@ export function ReviewWorkflowsCreateView() {
currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty },
},
} = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState);
const [showLimitModal, setShowLimitModal] = React.useState(false);
const { limits, isLoading: isLicenseLoading } = useReviewWorkflowLicenseLimits();
const { pagination, isLoading: isWorkflowLoading } = useReviewWorkflows();
const { mutateAsync, isLoading } = useMutation(
async ({ workflow }) => {
@ -88,6 +94,23 @@ export function ReviewWorkflowsCreateView() {
dispatch(resetWorkflow());
}, [dispatch]);
React.useEffect(() => {
if (!isWorkflowLoading && !isLicenseLoading) {
if (pagination?.total >= limits?.workflows) {
setShowLimitModal('workflow');
} else if (currentWorkflow.stages.length >= limits.stagesPerWorkflow) {
setShowLimitModal('stage');
}
}
}, [
currentWorkflow.stages.length,
isLicenseLoading,
isWorkflowLoading,
limits.stagesPerWorkflow,
limits?.workflows,
pagination?.total,
]);
return (
<>
<Layout.DragLayerRendered />
@ -141,6 +164,44 @@ export function ReviewWorkflowsCreateView() {
</Layout.Root>
</Form>
</FormikProvider>
<LimitsModal.Root
isOpen={showLimitModal === 'workflow'}
onClose={() => setShowLimitModal(false)}
>
<LimitsModal.Title>
{formatMessage({
id: 'Settings.review-workflows.create.page.workflows.limit.title',
defaultMessage: 'Youve reached the limit of workflows in your plan',
})}
</LimitsModal.Title>
<LimitsModal.Body>
{formatMessage({
id: 'Settings.review-workflows.create.page.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: 'Settings.review-workflows.create.page.stages.limit.title',
defaultMessage: 'You have reached the limit of stages for this workflow in your plan',
})}
</LimitsModal.Title>
<LimitsModal.Body>
{formatMessage({
id: 'Settings.review-workflows.create.page.stages.limit.body',
defaultMessage: 'Try deleting some stages or contact Sales to enable more stages.',
})}
</LimitsModal.Body>
</LimitsModal.Root>
</>
);
}

View File

@ -19,9 +19,11 @@ import { useContentTypes } from '../../../../../../../../admin/src/hooks/useCont
import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer';
import { setWorkflow } from '../../actions';
import * as Layout from '../../components/Layout';
import * as LimitsModal from '../../components/LimitsModal';
import { Stages } from '../../components/Stages';
import { WorkflowAttributes } from '../../components/WorkflowAttributes';
import { REDUX_NAMESPACE } from '../../constants';
import { useReviewWorkflowLicenseLimits } from '../../hooks/useReviewWorkflowLicenseLimits';
import { useReviewWorkflows } from '../../hooks/useReviewWorkflows';
import { reducer, initialState } from '../../reducer';
import { getWorkflowValidationSchema } from '../../utils/getWorkflowValidationSchema';
@ -35,6 +37,8 @@ export function ReviewWorkflowsEditView() {
const { formatAPIError } = useAPIErrorHandler();
const toggleNotification = useNotification();
const {
isLoading: isWorkflowLoading,
pagination,
workflows: [workflow],
status: workflowStatus,
refetch,
@ -51,6 +55,8 @@ export function ReviewWorkflowsEditView() {
},
} = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState);
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = React.useState(false);
const { limits, isLoading: isLicenseLoading } = useReviewWorkflowLicenseLimits();
const [showLimitModal, setShowLimitModal] = React.useState(false);
const { mutateAsync, isLoading } = useMutation(
async ({ workflow }) => {
@ -126,6 +132,23 @@ export function ReviewWorkflowsEditView() {
dispatch(setWorkflow({ status: workflowStatus, data: workflow }));
}, [workflowStatus, workflow, dispatch]);
React.useEffect(() => {
if (!isWorkflowLoading && !isLicenseLoading) {
if (pagination?.total >= limits?.workflows) {
setShowLimitModal('workflow');
} else if (currentWorkflow.stages.length >= limits?.stagesPerWorkflow) {
setShowLimitModal('stage');
}
}
}, [
currentWorkflow.stages.length,
isLicenseLoading,
isWorkflowLoading,
limits?.stagesPerWorkflow,
limits?.workflows,
pagination?.total,
]);
// TODO redirect back to list-view if workflow is not found?
return (
@ -191,6 +214,44 @@ export function ReviewWorkflowsEditView() {
onToggleDialog={toggleConfirmDeleteDialog}
onConfirm={handleConfirmDeleteDialog}
/>
<LimitsModal.Root
isOpen={showLimitModal === 'workflow'}
onClose={() => setShowLimitModal(false)}
>
<LimitsModal.Title>
{formatMessage({
id: 'Settings.review-workflows.edit.page.workflows.limit.title',
defaultMessage: 'Youve reached the limit of workflows in your plan',
})}
</LimitsModal.Title>
<LimitsModal.Body>
{formatMessage({
id: 'Settings.review-workflows.edit.page.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: 'Settings.review-workflows.edit.page.stages.limit.title',
defaultMessage: 'You have reached the limit of stages for this workflow in your plan',
})}
</LimitsModal.Title>
<LimitsModal.Body>
{formatMessage({
id: 'Settings.review-workflows.edit.page.stages.limit.body',
defaultMessage: 'Try deleting some stages or contact Sales to enable more stages.',
})}
</LimitsModal.Body>
</LimitsModal.Root>
</>
);
}

View File

@ -32,6 +32,8 @@ import styled from 'styled-components';
import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes';
import * as Layout from '../../components/Layout';
import * as LimitsModal from '../../components/LimitsModal';
import { useReviewWorkflowLicenseLimits } from '../../hooks/useReviewWorkflowLicenseLimits';
import { useReviewWorkflows } from '../../hooks/useReviewWorkflows';
const ActionLink = styled(Link)`
@ -65,11 +67,13 @@ export function ReviewWorkflowsListView() {
const { formatMessage } = useIntl();
const { push } = useHistory();
const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes();
const { workflows, isLoading, refetch } = useReviewWorkflows();
const { pagination, workflows, isLoading, refetch } = useReviewWorkflows();
const [workflowToDelete, setWorkflowToDelete] = React.useState(null);
const [showLimitModal, setShowLimitModal] = React.useState(false);
const { del } = useFetchClient();
const { formatAPIError } = useAPIErrorHandler();
const toggleNotification = useNotification();
const { limits } = useReviewWorkflowLicenseLimits();
const { mutateAsync, isLoading: isLoadingMutation } = useMutation(
async ({ workflowId, stages }) => {
@ -129,7 +133,17 @@ export function ReviewWorkflowsListView() {
<>
<Layout.Header
primaryAction={
<LinkButton startIcon={<Plus />} size="S" to="/settings/review-workflows/create">
<LinkButton
startIcon={<Plus />}
size="S"
to="/settings/review-workflows/create"
onClick={(event) => {
if (pagination?.total >= limits.workflows) {
event.preventDefault();
setShowLimitModal(true);
}
}}
>
{formatMessage({
id: 'Settings.review-workflows.list.page.create',
defaultMessage: 'Create new workflow',
@ -158,9 +172,18 @@ export function ReviewWorkflowsListView() {
) : (
<Table
colCount={3}
// TODO: we should be able to use a link here instead of an (inaccessible onClick) handler
footer={
<TFooter icon={<Plus />} onClick={() => push('/settings/review-workflows/create')}>
// TODO: we should be able to use a link here instead of an (inaccessible onClick) handler
<TFooter
icon={<Plus />}
onClick={() => {
if (pagination?.total >= limits?.workflows) {
setShowLimitModal(true);
} else {
push('/settings/review-workflows/create');
}
}}
>
{formatMessage({
id: 'Settings.review-workflows.list.page.create',
defaultMessage: 'Create new workflow',
@ -283,6 +306,22 @@ export function ReviewWorkflowsListView() {
onToggleDialog={toggleConfirmDeleteDialog}
onConfirm={handleConfirmDeleteDialog}
/>
<LimitsModal.Root isOpen={showLimitModal} onClose={() => setShowLimitModal(false)}>
<LimitsModal.Title>
{formatMessage({
id: 'Settings.review-workflows.list.page.workflows.limit.title',
defaultMessage: 'Youve reached the limit of workflows in your plan',
})}
</LimitsModal.Title>
<LimitsModal.Body>
{formatMessage({
id: 'Settings.review-workflows.list.page.workflows.limit.body',
defaultMessage: 'Delete a workflow or contact Sales to enable more workflows.',
})}
</LimitsModal.Body>
</LimitsModal.Root>
</Layout.Root>
</>
);