diff --git a/mailpoet/assets/css/src/components-automation-editor/_errors.scss b/mailpoet/assets/css/src/components-automation-editor/_errors.scss new file mode 100644 index 0000000000..cd767b18fd --- /dev/null +++ b/mailpoet/assets/css/src/components-automation-editor/_errors.scss @@ -0,0 +1,32 @@ +.mailpoet-automation-errors { + padding: 8px 0; + width: 280px; +} + +.mailpoet-automation-errors-header { + font-weight: 600; + padding: 8px 12px; +} + +.mailpoet-automation-step-error { + align-items: center; + appearance: none; + background: none; + border: none; + cursor: pointer; + display: grid; + gap: 12px; + grid-template-columns: auto 1fr; + padding: 9px 12px; + text-align: left; + width: 100%; + + &:hover { + background: #f6f7f7; + } + + &:focus-visible { + box-shadow: inset 0 0 0 1.5px #2271b1; + outline: none; + } +} diff --git a/mailpoet/assets/css/src/mailpoet-automation-editor.scss b/mailpoet/assets/css/src/mailpoet-automation-editor.scss index 6488a8e3f2..9f80b897d3 100644 --- a/mailpoet/assets/css/src/mailpoet-automation-editor.scss +++ b/mailpoet/assets/css/src/mailpoet-automation-editor.scss @@ -8,6 +8,7 @@ @import './components-automation-editor/chip'; @import './components-automation-editor/dropdown'; @import './components-automation-editor/empty-workflow'; +@import './components-automation-editor/errors'; @import './components-automation-editor/panel'; @import './components-automation-editor/separator'; @import './components-automation-editor/status'; diff --git a/mailpoet/assets/js/src/automation/editor/components/header/errors.tsx b/mailpoet/assets/js/src/automation/editor/components/header/errors.tsx new file mode 100644 index 0000000000..6f03119fe6 --- /dev/null +++ b/mailpoet/assets/js/src/automation/editor/components/header/errors.tsx @@ -0,0 +1,169 @@ +import { ComponentType, useContext, useEffect, useMemo, useState } from 'react'; +import { + __unstableComposite as Composite, + __unstableCompositeItem as CompositeItem, + __unstableUseCompositeState as useCompositeState, + Button, + Popover as WpPopover, +} from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { createContext } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Chip } from '../chip'; +import { ColoredIcon } from '../icons'; +import { + StepError as StepErrorType, + stepSidebarKey, + storeName, +} from '../../store'; + +// properties "offset" and "placement" are missing in WpPopover type definition +const Popover: ComponentType< + WpPopover.Props & { + offset?: number; + placement?: string; + } +> = WpPopover; + +export const ErrorsCompositeContext = + createContext>(undefined); + +type StepErrorProps = { + stepId: string; +}; + +function StepError({ stepId }: StepErrorProps): JSX.Element { + const compositeState = useContext(ErrorsCompositeContext); + + const { steps, workflowData } = useSelect( + (select) => ({ + steps: select(storeName).getSteps(), + workflowData: select(storeName).getWorkflowData(), + }), + [], + ); + + const { openSidebar, selectStep } = useDispatch(storeName); + + const stepData = workflowData.steps[stepId]; + const step = steps.find(({ key }) => key === stepData.key); + + return ( + { + openSidebar(stepSidebarKey); + selectStep(stepData); + }} + > + + {step.title} + + ); +} + +export function Errors(): JSX.Element | null { + const [showPopover, setShowPopover] = useState(false); + + const compositeState = useCompositeState({ + orientation: 'vertical', + shift: true, + }); + + const { errors, workflowData } = useSelect( + (select) => ({ + errors: select(storeName).getErrors(), + workflowData: select(storeName).getWorkflowData(), + }), + [], + ); + + // walk the steps tree (breadth first) to produce stable error order + const stepErrors = useMemo(() => { + if (!errors) { + return []; + } + + const visited = new Map(); + const ids = workflowData.steps.root.next_steps.map(({ id }) => id); + while (ids.length > 0) { + const id = ids.shift(); + if (!visited.has(id)) { + visited.set(id, errors.steps[id]); + workflowData.steps[id]?.next_steps?.forEach((step) => + ids.push(step.id), + ); + } + } + return [...visited.values()].filter((error) => !!error); + }, [errors, workflowData]); + + // automatically open the popover when errors appear + const hasErrors = stepErrors.length > 0; + useEffect(() => { + if (hasErrors) { + setShowPopover(true); + } + }, [hasErrors]); + + if (stepErrors.length === 0) { + return null; + } + + return ( +
+ + {showPopover && ( + + setShowPopover((prevState) => + prevState === undefined ? undefined : false, + ) + } + > + + +
+ {__('The following steps are not fully set:', 'mailpoet')} +
+ {stepErrors.map((error) => ( + + ))} +
+
+
+ )} +
+ ); +} diff --git a/mailpoet/assets/js/src/automation/editor/components/header/index.tsx b/mailpoet/assets/js/src/automation/editor/components/header/index.tsx index cd7f92ddb7..b0e11eb23d 100644 --- a/mailpoet/assets/js/src/automation/editor/components/header/index.tsx +++ b/mailpoet/assets/js/src/automation/editor/components/header/index.tsx @@ -3,9 +3,9 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { PinnedItems } from '@wordpress/interface'; import { __ } from '@wordpress/i18n'; import { DocumentActions } from './document_actions'; +import { Errors } from './errors'; import { InserterToggle } from './inserter_toggle'; import { MoreMenu } from './more_menu'; -import { Chip } from '../chip'; import { storeName } from '../../store'; import { WorkflowStatus } from '../../../listing/workflow'; @@ -14,10 +14,22 @@ import { WorkflowStatus } from '../../../listing/workflow'; // https://github.com/WordPress/gutenberg/blob/0ee78b1bbe9c6f3e6df99f3b967132fa12bef77d/packages/edit-site/src/components/header/index.js function ActivateButton(): JSX.Element { + const { errors } = useSelect( + (select) => ({ + errors: select(storeName).getErrors(), + }), + [], + ); + const { activate } = useDispatch(storeName); return ( - ); @@ -49,11 +61,10 @@ type Props = { export function Header({ showInserterToggle }: Props): JSX.Element { const { setWorkflowName } = useDispatch(storeName); - const { workflowName, workflowStatus, errors } = useSelect( + const { workflowName, workflowStatus } = useSelect( (select) => ({ workflowName: select(storeName).getWorkflowData().name, workflowStatus: select(storeName).getWorkflowData().status, - errors: select(storeName).getErrors(), }), [], ); @@ -91,9 +102,7 @@ export function Header({ showInserterToggle }: Props): JSX.Element {
- {errors && Object.values(errors.steps).length > 0 && ( - {Object.values(errors.steps).length} issues - )} + {workflowStatus !== WorkflowStatus.ACTIVE && } {workflowStatus === WorkflowStatus.ACTIVE && }