Enhancement: Combine content-type reassignment + stage prompts

This commit is contained in:
Gustav Hansen 2023-07-21 13:42:09 +02:00
parent 37d0f58de2
commit 9c8d157f5d
9 changed files with 387 additions and 80 deletions

View File

@ -1,15 +1,37 @@
import * as React from 'react';
import { Grid, GridItem, MultiSelectNested, TextInput } from '@strapi/design-system';
import {
Grid,
GridItem,
MultiSelect,
MultiSelectGroup,
MultiSelectOption,
TextInput,
Typography,
} from '@strapi/design-system';
import { useCollator } from '@strapi/helper-plugin';
import { useField } from 'formik';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { updateWorkflow } from '../../actions';
export function WorkflowAttributes({ canUpdate, contentTypes: { collectionTypes, singleTypes } }) {
const NestedOption = styled(MultiSelectOption)`
padding-left: ${({ theme }) => theme.spaces[7]};
`;
const ContentTypeTakeNotice = styled(Typography)`
font-style: italic;
`;
export function WorkflowAttributes({
canUpdate,
contentTypes: { collectionTypes, singleTypes },
currentWorkflow,
workflows,
}) {
const { formatMessage, locale } = useIntl();
const dispatch = useDispatch();
const [nameField, nameMeta, nameHelper] = useField('name');
@ -39,7 +61,7 @@ export function WorkflowAttributes({ canUpdate, contentTypes: { collectionTypes,
</GridItem>
<GridItem col={6}>
<MultiSelectNested
<MultiSelect
{...contentTypesField}
customizeContent={(value) =>
formatMessage(
@ -62,7 +84,12 @@ export function WorkflowAttributes({ canUpdate, contentTypes: { collectionTypes,
dispatch(updateWorkflow({ contentTypes: values }));
contentTypesHelper.setValue(values);
}}
options={[
placeholder={formatMessage({
id: 'Settings.review-workflows.workflow.contentTypes.placeholder',
defaultMessage: 'Select',
})}
>
{[
...(collectionTypes.length > 0
? [
{
@ -94,12 +121,53 @@ export function WorkflowAttributes({ canUpdate, contentTypes: { collectionTypes,
},
]
: []),
]}
placeholder={formatMessage({
id: 'Settings.review-workflows.workflow.contentTypes.placeholder',
defaultMessage: 'Select',
].map((opt) => {
if ('children' in opt) {
return (
<MultiSelectGroup
key={opt.label}
label={opt.label}
values={opt.children.map((child) => child.value.toString())}
>
{opt.children.map((child) => {
const { name: assignedWorkflowName } =
workflows.find(
(workflow) =>
((currentWorkflow && workflow.id !== currentWorkflow.id) ||
!currentWorkflow) &&
workflow.contentTypes.includes(child.value)
) ?? {};
return (
<NestedOption key={child.value} value={child.value}>
{formatMessage(
{
id: 'Settings.review-workflows.workflow.contentTypes.assigned.notice',
defaultMessage:
'{label} {name, select, undefined {} other {<i>(assigned to {name} workflow)</i>}}',
},
{
label: child.label,
name: assignedWorkflowName,
i: (...children) => (
<ContentTypeTakeNotice>{children}</ContentTypeTakeNotice>
),
}
)}
</NestedOption>
);
})}
</MultiSelectGroup>
);
}
return (
<MultiSelectOption key={opt.value} value={opt.value}>
{opt.label}
</MultiSelectOption>
);
})}
/>
</MultiSelect>
</GridItem>
</Grid>
);
@ -114,6 +182,7 @@ const ContentTypeType = PropTypes.shape({
WorkflowAttributes.defaultProps = {
canUpdate: true,
currentWorkflow: undefined,
};
WorkflowAttributes.propTypes = {
@ -122,4 +191,6 @@ WorkflowAttributes.propTypes = {
collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired,
singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired,
}).isRequired,
currentWorkflow: PropTypes.object,
workflows: PropTypes.array.isRequired,
};

View File

@ -1,7 +1,8 @@
import * as React from 'react';
import { Button, Flex, Loader } from '@strapi/design-system';
import { Button, Flex, Loader, Typography } from '@strapi/design-system';
import {
ConfirmDialog,
useAPIErrorHandler,
useFetchClient,
useNotification,
@ -42,6 +43,7 @@ export function ReviewWorkflowsCreateView() {
const permissions = useSelector(selectAdminPermissions);
const toggleNotification = useNotification();
const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes();
const { isLoading: isWorkflowLoading, meta, workflows } = useReviewWorkflows();
const {
clientState: {
currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty },
@ -52,8 +54,11 @@ export function ReviewWorkflowsCreateView() {
} = useRBAC(permissions.settings['review-workflows']);
const [showLimitModal, setShowLimitModal] = React.useState(false);
const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits();
const { meta, isLoading: isWorkflowLoading } = useReviewWorkflows();
const [initialErrors, setInitialErrors] = React.useState(null);
const [savePrompts, setSavePrompts] = React.useState({});
const limits = getFeature('review-workflows');
const contentTypesFromOtherWorkflows = workflows.flatMap((workflow) => workflow.contentTypes);
const { mutateAsync, isLoading } = useMutation(
async ({ workflow }) => {
@ -79,6 +84,8 @@ export function ReviewWorkflowsCreateView() {
);
const submitForm = async () => {
setSavePrompts({});
try {
const workflow = await mutateAsync({ workflow: currentWorkflow });
@ -110,13 +117,23 @@ export function ReviewWorkflowsCreateView() {
}
};
const limits = getFeature('review-workflows');
const handleConfirmDeleteDialog = async () => {
await submitForm();
};
const handleConfirmClose = () => {
setSavePrompts({});
};
const formik = useFormik({
enableReinitialize: true,
initialErrors,
initialValues: currentWorkflow,
async onSubmit() {
const isContentTypeReassignment = currentWorkflow.contentTypes.some((contentType) =>
contentTypesFromOtherWorkflows.includes(contentType)
);
/**
* If the current license has a limit, check if the total count of workflows
* exceeds that limit and display the limits modal instead of sending the
@ -140,6 +157,8 @@ export function ReviewWorkflowsCreateView() {
parseInt(limits[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME], 10)
) {
setShowLimitModal('stage');
} else if (isContentTypeReassignment) {
setSavePrompts((prev) => ({ ...prev, hasReassignedContentTypes: true }));
} else {
submitForm();
}
@ -243,7 +262,10 @@ export function ReviewWorkflowsCreateView() {
</Loader>
) : (
<Flex alignItems="stretch" direction="column" gap={7}>
<WorkflowAttributes contentTypes={{ collectionTypes, singleTypes }} />
<WorkflowAttributes
contentTypes={{ collectionTypes, singleTypes }}
workflows={workflows}
/>
<Stages stages={formik.values?.stages} />
</Flex>
)}
@ -252,6 +274,41 @@ export function ReviewWorkflowsCreateView() {
</Form>
</FormikProvider>
<ConfirmDialog.Root
isConfirmButtonLoading={isLoading}
isOpen={Object.keys(savePrompts).length > 0}
onToggleDialog={handleConfirmClose}
onConfirm={handleConfirmDeleteDialog}
>
<ConfirmDialog.Body>
<Flex direction="column" gap={5}>
{savePrompts.hasReassignedContentTypes && (
<Typography textAlign="center" variant="omega">
{formatMessage(
{
id: 'Settings.review-workflows.page.delete.confirm.contentType.body',
defaultMessage:
'{count} {count, plural, one {content-type} other {content-types}} {count, plural, one {is} other {are}} already mapped to {count, plural, one {another workflow} other {other workflows}}. If you save changes, {count, plural, one {this} other {these}} {count, plural, one {content-type} other {{count} content-types}} will no more be mapped to the {count, plural, one {another workflow} other {other workflows}} and all corresponding information will be removed.',
},
{
count: contentTypesFromOtherWorkflows.filter((contentType) =>
currentWorkflow.contentTypes.includes(contentType)
).length,
}
)}
</Typography>
)}
<Typography textAlign="center" variant="omega">
{formatMessage({
id: 'Settings.review-workflows.page.delete.confirm.confirm',
defaultMessage: 'Are you sure you want to save?',
})}
</Typography>
</Flex>
</ConfirmDialog.Body>
</ConfirmDialog.Root>
<LimitsModal.Root
isOpen={showLimitModal === 'workflow'}
onClose={() => setShowLimitModal(false)}

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { Button, Flex, Loader } from '@strapi/design-system';
import { Button, Flex, Loader, Typography } from '@strapi/design-system';
import {
ConfirmDialog,
useAPIErrorHandler,
@ -45,10 +45,10 @@ export function ReviewWorkflowsEditView() {
const {
isLoading: isWorkflowLoading,
meta,
workflows: [workflow],
workflows,
status: workflowStatus,
refetch,
} = useReviewWorkflows({ id: workflowId });
} = useReviewWorkflows();
const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes();
const {
status,
@ -56,18 +56,23 @@ export function ReviewWorkflowsEditView() {
currentWorkflow: {
data: currentWorkflow,
isDirty: currentWorkflowIsDirty,
hasDeletedServerStages: currentWorkflowHasDeletedServerStages,
hasDeletedServerStages,
},
},
} = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState);
const {
allowedActions: { canDelete, canUpdate },
} = useRBAC(permissions.settings['review-workflows']);
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = React.useState(false);
const [savePrompts, setSavePrompts] = React.useState({});
const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits();
const [showLimitModal, setShowLimitModal] = React.useState(false);
const [initialErrors, setInitialErrors] = React.useState(null);
const workflow = workflows.find((workflow) => workflow.id === parseInt(workflowId, 10));
const contentTypesFromOtherWorkflows = workflows
.filter((workflow) => workflow.id !== parseInt(workflowId, 10))
.flatMap((workflow) => workflow.contentTypes);
const { mutateAsync, isLoading } = useMutation(
async ({ workflow }) => {
const {
@ -125,15 +130,15 @@ export function ReviewWorkflowsEditView() {
await updateWorkflow(currentWorkflow);
await refetch();
setIsConfirmDeleteDialogOpen(false);
setSavePrompts({});
};
const handleConfirmDeleteDialog = async () => {
await submitForm();
};
const toggleConfirmDeleteDialog = () => {
setIsConfirmDeleteDialogOpen((prev) => !prev);
const handleConfirmClose = () => {
setSavePrompts({});
};
const formik = useFormik({
@ -141,9 +146,11 @@ export function ReviewWorkflowsEditView() {
initialErrors,
initialValues: currentWorkflow,
async onSubmit() {
if (currentWorkflowHasDeletedServerStages) {
setIsConfirmDeleteDialogOpen(true);
} else if (
const isContentTypeReassignment = currentWorkflow.contentTypes.some((contentType) =>
contentTypesFromOtherWorkflows.includes(contentType)
);
if (
limits?.[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME] &&
meta?.workflowCount > parseInt(limits[CHARGEBEE_WORKFLOW_ENTITLEMENT_NAME], 10)
) {
@ -165,6 +172,14 @@ export function ReviewWorkflowsEditView() {
parseInt(limits[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME], 10)
) {
setShowLimitModal('stage');
} else if (hasDeletedServerStages || isContentTypeReassignment) {
if (hasDeletedServerStages) {
setSavePrompts((prev) => ({ ...prev, hasDeletedServerStages: true }));
}
if (isContentTypeReassignment) {
setSavePrompts((prev) => ({ ...prev, hasReassignedContentTypes: true }));
}
} else {
submitForm();
}
@ -243,7 +258,7 @@ export function ReviewWorkflowsEditView() {
disabled={!currentWorkflowIsDirty || !canUpdate}
// if the confirm dialog is open the loading state is on
// the confirm button already
loading={!isConfirmDeleteDialogOpen && isLoading}
loading={!Object.keys(savePrompts).length > 0 && isLoading}
>
{formatMessage({
id: 'global.save',
@ -279,6 +294,8 @@ export function ReviewWorkflowsEditView() {
<WorkflowAttributes
canUpdate={canUpdate}
contentTypes={{ collectionTypes, singleTypes }}
currentWorkflow={currentWorkflow}
workflows={workflows}
/>
<Stages
canDelete={canDelete}
@ -291,17 +308,50 @@ export function ReviewWorkflowsEditView() {
</Form>
</FormikProvider>
<ConfirmDialog
bodyText={{
id: 'Settings.review-workflows.page.delete.confirm.body',
defaultMessage:
'All entries assigned to deleted stages will be moved to the previous stage. Are you sure you want to save?',
}}
<ConfirmDialog.Root
isConfirmButtonLoading={isLoading}
isOpen={isConfirmDeleteDialogOpen}
onToggleDialog={toggleConfirmDeleteDialog}
isOpen={Object.keys(savePrompts).length > 0}
onToggleDialog={handleConfirmClose}
onConfirm={handleConfirmDeleteDialog}
/>
>
<ConfirmDialog.Body>
<Flex direction="column" gap={5}>
{savePrompts.hasDeletedServerStages && (
<Typography textAlign="center" variant="omega">
{formatMessage({
id: 'Settings.review-workflows.page.delete.confirm.stages.body',
defaultMessage:
'All entries assigned to deleted stages will be moved to the previous stage.',
})}
</Typography>
)}
{savePrompts.hasReassignedContentTypes && (
<Typography textAlign="center" variant="omega">
{formatMessage(
{
id: 'Settings.review-workflows.page.delete.confirm.contentType.body',
defaultMessage:
'{count} {count, plural, one {content-type} other {content-types}} {count, plural, one {is} other {are}} already mapped to {count, plural, one {another workflow} other {other workflows}}. If you save changes, {count, plural, one {this} other {these}} {count, plural, one {content-type} other {{count} content-types}} will no more be mapped to the {count, plural, one {another workflow} other {other workflows}} and all corresponding information will be removed.',
},
{
count: contentTypesFromOtherWorkflows.filter((contentType) =>
currentWorkflow.contentTypes.includes(contentType)
).length,
}
)}
</Typography>
)}
<Typography textAlign="center" variant="omega">
{formatMessage({
id: 'Settings.review-workflows.page.delete.confirm.confirm',
defaultMessage: 'Are you sure you want to save?',
})}
</Typography>
</Flex>
</ConfirmDialog.Body>
</ConfirmDialog.Root>
<LimitsModal.Root
isOpen={showLimitModal === 'workflow'}

View File

@ -73,7 +73,7 @@ export function reducer(state = initialState, action) {
if (!currentWorkflow.hasDeletedServerStages) {
draft.clientState.currentWorkflow.hasDeletedServerStages = !!(
state.serverState.currentWorkflow?.stages ?? []
state.serverState.workflow?.stages ?? []
).find((stage) => stage.id === stageId);
}

View File

@ -1,18 +1,25 @@
import React from 'react';
import { Button, Dialog, DialogBody, DialogFooter, Flex, Typography } from '@strapi/design-system';
import {
Button,
Box,
Dialog,
DialogBody,
DialogFooter,
Flex,
Typography,
} from '@strapi/design-system';
import { ExclamationMarkCircle, Trash } from '@strapi/icons';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
const ConfirmDialog = ({
bodyText,
export const Root = ({
children,
iconRightButton,
iconBody,
isConfirmButtonLoading,
leftButtonText,
onToggleDialog,
onConfirm,
onToggleDialog,
rightButtonText,
title,
variantRightButton,
@ -31,46 +38,165 @@ const ConfirmDialog = ({
describedBy="confirm-description"
{...props}
>
<DialogBody icon={iconBody}>
<Flex direction="column" alignItems="stretch" gap={2}>
<Flex justifyContent="center">
<Typography variant="omega" id="confirm-description">
{formatMessage({
id: bodyText.id,
defaultMessage: bodyText.defaultMessage,
})}
</Typography>
</Flex>
</Flex>
</DialogBody>
<DialogFooter
startAction={
<Button onClick={onToggleDialog} variant="tertiary">
{formatMessage({
id: leftButtonText.id,
defaultMessage: leftButtonText.defaultMessage,
})}
</Button>
}
endAction={
<Button
onClick={onConfirm}
variant={variantRightButton}
startIcon={iconRightButton}
id="confirm-delete"
loading={isConfirmButtonLoading}
>
{formatMessage({
id: rightButtonText.id,
defaultMessage: rightButtonText.defaultMessage,
})}
</Button>
}
<Box id="confirm-description">{children}</Box>
<Footer
iconRightButton={iconRightButton}
isConfirmButtonLoading={isConfirmButtonLoading}
leftButtonText={leftButtonText}
onConfirm={onConfirm}
onToggleDialog={onToggleDialog}
rightButtonText={rightButtonText}
variantRightButton={variantRightButton}
/>
</Dialog>
);
};
Root.defaultProps = {
iconBody: <ExclamationMarkCircle />,
iconRightButton: <Trash />,
isConfirmButtonLoading: false,
leftButtonText: {
id: 'app.components.Button.cancel',
defaultMessage: 'Cancel',
},
rightButtonText: {
id: 'app.components.Button.confirm',
defaultMessage: 'Confirm',
},
title: {
id: 'app.components.ConfirmDialog.title',
defaultMessage: 'Confirmation',
},
variantRightButton: 'danger-light',
};
Root.propTypes = {
children: PropTypes.node.isRequired,
iconBody: PropTypes.node,
iconRightButton: PropTypes.node,
isConfirmButtonLoading: PropTypes.bool,
onConfirm: PropTypes.func.isRequired,
onToggleDialog: PropTypes.func.isRequired,
leftButtonText: PropTypes.shape({
id: PropTypes.string,
defaultMessage: PropTypes.string,
}),
rightButtonText: PropTypes.shape({
id: PropTypes.string,
defaultMessage: PropTypes.string,
}),
title: PropTypes.shape({
id: PropTypes.string,
defaultMessage: PropTypes.string,
}),
variantRightButton: PropTypes.string,
};
export const Body = ({ iconBody, children }) => {
return (
<DialogBody icon={iconBody}>
<Flex direction="column" alignItems="stretch" gap={2}>
<Flex justifyContent="center">{children}</Flex>
</Flex>
</DialogBody>
);
};
Body.defaultProps = {
iconBody: <ExclamationMarkCircle />,
};
Body.propTypes = {
children: PropTypes.node.isRequired,
iconBody: PropTypes.node,
};
const Footer = ({
iconRightButton,
isConfirmButtonLoading,
leftButtonText,
onConfirm,
onToggleDialog,
rightButtonText,
variantRightButton,
}) => {
const { formatMessage } = useIntl();
return (
<DialogFooter
startAction={
<Button onClick={onToggleDialog} variant="tertiary">
{formatMessage({
id: leftButtonText.id,
defaultMessage: leftButtonText.defaultMessage,
})}
</Button>
}
endAction={
<Button
onClick={onConfirm}
variant={variantRightButton}
startIcon={iconRightButton}
id="confirm-delete"
loading={isConfirmButtonLoading}
>
{formatMessage({
id: rightButtonText.id,
defaultMessage: rightButtonText.defaultMessage,
})}
</Button>
}
/>
);
};
Footer.propTypes = {
iconRightButton: PropTypes.node.isRequired,
isConfirmButtonLoading: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired,
onToggleDialog: PropTypes.func.isRequired,
leftButtonText: PropTypes.shape({
id: PropTypes.string,
defaultMessage: PropTypes.string,
}).isRequired,
rightButtonText: PropTypes.shape({
id: PropTypes.string,
defaultMessage: PropTypes.string,
}).isRequired,
variantRightButton: PropTypes.string.isRequired,
};
const ConfirmDialog = ({
bodyText,
iconRightButton,
iconBody,
isConfirmButtonLoading,
leftButtonText,
onToggleDialog,
onConfirm,
rightButtonText,
title,
variantRightButton,
...props
}) => {
const { formatMessage } = useIntl();
return (
<Root onConfirm={onConfirm} onToggleDialog={onToggleDialog} title={title} {...props}>
<Body>
<Typography variant="omega">
{formatMessage({
id: bodyText.id,
defaultMessage: bodyText.defaultMessage,
})}
</Typography>
</Body>
</Root>
);
};
ConfirmDialog.defaultProps = {
bodyText: {
id: 'components.popUpWarning.message',
@ -119,4 +245,7 @@ ConfirmDialog.propTypes = {
variantRightButton: PropTypes.string,
};
export default ConfirmDialog;
ConfirmDialog.Root = Root;
ConfirmDialog.Body = Body;
export { ConfirmDialog };

View File

@ -4,7 +4,7 @@ import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { render, screen, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import ConfirmDialog from '../index';
import { ConfirmDialog } from '../index';
const App = (
<ThemeProvider theme={lightTheme}>

View File

@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
import { useTracking } from '../../features/Tracking';
import useQueryParams from '../../hooks/useQueryParams';
import ConfirmDialog from '../ConfirmDialog';
import { ConfirmDialog } from '../ConfirmDialog';
import EmptyBodyTable from '../EmptyBodyTable';
import TableHead from './TableHead';

View File

@ -23,7 +23,7 @@ import { useIntl } from 'react-intl';
import useQueryParams from '../../hooks/useQueryParams';
import SortIcon from '../../icons/SortIcon';
import ConfirmDialog from '../ConfirmDialog';
import { ConfirmDialog } from '../ConfirmDialog';
import EmptyStateLayout from '../EmptyStateLayout';
/* -------------------------------------------------------------------------------------------------

View File

@ -7,7 +7,7 @@ import { getOtherInfos, getType } from './content-manager/utils/getAttributeInfo
export { default as AnErrorOccurred } from './components/AnErrorOccurred';
export { default as CheckPagePermissions } from './components/CheckPagePermissions';
export { default as CheckPermissions } from './components/CheckPermissions';
export { default as ConfirmDialog } from './components/ConfirmDialog';
export * from './components/ConfirmDialog';
export { default as ContentBox } from './components/ContentBox';
export { default as DateTimePicker } from './components/DateTimePicker';
export { default as DynamicTable } from './components/DynamicTable';