Implement workflow error list popover in header
[MAILPOET-4659]
This commit is contained in:
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@
|
|||||||
@import './components-automation-editor/chip';
|
@import './components-automation-editor/chip';
|
||||||
@import './components-automation-editor/dropdown';
|
@import './components-automation-editor/dropdown';
|
||||||
@import './components-automation-editor/empty-workflow';
|
@import './components-automation-editor/empty-workflow';
|
||||||
|
@import './components-automation-editor/errors';
|
||||||
@import './components-automation-editor/panel';
|
@import './components-automation-editor/panel';
|
||||||
@import './components-automation-editor/separator';
|
@import './components-automation-editor/separator';
|
||||||
@import './components-automation-editor/status';
|
@import './components-automation-editor/status';
|
||||||
|
@ -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<ReturnType<typeof useCompositeState>>(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 (
|
||||||
|
<CompositeItem
|
||||||
|
className="mailpoet-automation-step-error"
|
||||||
|
role="listitem"
|
||||||
|
state={compositeState}
|
||||||
|
onClick={() => {
|
||||||
|
openSidebar(stepSidebarKey);
|
||||||
|
selectStep(stepData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ColoredIcon
|
||||||
|
icon={step.icon}
|
||||||
|
foreground={step.foreground}
|
||||||
|
background={step.background}
|
||||||
|
width="23px"
|
||||||
|
height="23px"
|
||||||
|
/>
|
||||||
|
{step.title}
|
||||||
|
</CompositeItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, StepErrorType | undefined>();
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() =>
|
||||||
|
setShowPopover((prevState) =>
|
||||||
|
prevState === undefined ? false : !prevState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onMouseDown={() =>
|
||||||
|
// Catch and mark a mouse down event from an open popover with "undefined" to avoid closing it
|
||||||
|
// (automatically via click outside) and reopening it right after (via the onClick handler).
|
||||||
|
// The "onClose" method of the popover doesn't pass any events so we can't filter them.
|
||||||
|
setShowPopover((prevState) => (prevState ? undefined : prevState))
|
||||||
|
}
|
||||||
|
style={{ textDecoration: 'none', borderRadius: 99999 }}
|
||||||
|
>
|
||||||
|
<Chip>{stepErrors.length} issues</Chip>
|
||||||
|
</Button>
|
||||||
|
{showPopover && (
|
||||||
|
<Popover
|
||||||
|
offset={10}
|
||||||
|
placement="bottom-end"
|
||||||
|
onClose={() =>
|
||||||
|
setShowPopover((prevState) =>
|
||||||
|
prevState === undefined ? undefined : false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ErrorsCompositeContext.Provider value={compositeState}>
|
||||||
|
<Composite
|
||||||
|
state={compositeState}
|
||||||
|
role="list"
|
||||||
|
aria-label={__('Workflow errors', 'mailpoet')}
|
||||||
|
className="mailpoet-automation-errors"
|
||||||
|
>
|
||||||
|
<div className="mailpoet-automation-errors-header">
|
||||||
|
{__('The following steps are not fully set:', 'mailpoet')}
|
||||||
|
</div>
|
||||||
|
{stepErrors.map((error) => (
|
||||||
|
<StepError key={error.step_id} stepId={error.step_id} />
|
||||||
|
))}
|
||||||
|
</Composite>
|
||||||
|
</ErrorsCompositeContext.Provider>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -3,9 +3,9 @@ import { useDispatch, useSelect } from '@wordpress/data';
|
|||||||
import { PinnedItems } from '@wordpress/interface';
|
import { PinnedItems } from '@wordpress/interface';
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
import { DocumentActions } from './document_actions';
|
import { DocumentActions } from './document_actions';
|
||||||
|
import { Errors } from './errors';
|
||||||
import { InserterToggle } from './inserter_toggle';
|
import { InserterToggle } from './inserter_toggle';
|
||||||
import { MoreMenu } from './more_menu';
|
import { MoreMenu } from './more_menu';
|
||||||
import { Chip } from '../chip';
|
|
||||||
import { storeName } from '../../store';
|
import { storeName } from '../../store';
|
||||||
import { WorkflowStatus } from '../../../listing/workflow';
|
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
|
// https://github.com/WordPress/gutenberg/blob/0ee78b1bbe9c6f3e6df99f3b967132fa12bef77d/packages/edit-site/src/components/header/index.js
|
||||||
|
|
||||||
function ActivateButton(): JSX.Element {
|
function ActivateButton(): JSX.Element {
|
||||||
|
const { errors } = useSelect(
|
||||||
|
(select) => ({
|
||||||
|
errors: select(storeName).getErrors(),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const { activate } = useDispatch(storeName);
|
const { activate } = useDispatch(storeName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button isPrimary className="editor-post-publish-button" onClick={activate}>
|
<Button
|
||||||
|
isPrimary
|
||||||
|
className="editor-post-publish-button"
|
||||||
|
onClick={activate}
|
||||||
|
disabled={!!errors}
|
||||||
|
>
|
||||||
Activate
|
Activate
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@ -49,11 +61,10 @@ type Props = {
|
|||||||
|
|
||||||
export function Header({ showInserterToggle }: Props): JSX.Element {
|
export function Header({ showInserterToggle }: Props): JSX.Element {
|
||||||
const { setWorkflowName } = useDispatch(storeName);
|
const { setWorkflowName } = useDispatch(storeName);
|
||||||
const { workflowName, workflowStatus, errors } = useSelect(
|
const { workflowName, workflowStatus } = useSelect(
|
||||||
(select) => ({
|
(select) => ({
|
||||||
workflowName: select(storeName).getWorkflowData().name,
|
workflowName: select(storeName).getWorkflowData().name,
|
||||||
workflowStatus: select(storeName).getWorkflowData().status,
|
workflowStatus: select(storeName).getWorkflowData().status,
|
||||||
errors: select(storeName).getErrors(),
|
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@ -91,9 +102,7 @@ export function Header({ showInserterToggle }: Props): JSX.Element {
|
|||||||
|
|
||||||
<div className="edit-site-header_end">
|
<div className="edit-site-header_end">
|
||||||
<div className="edit-site-header__actions">
|
<div className="edit-site-header__actions">
|
||||||
{errors && Object.values(errors.steps).length > 0 && (
|
<Errors />
|
||||||
<Chip>{Object.values(errors.steps).length} issues</Chip>
|
|
||||||
)}
|
|
||||||
<SaveDraftButton />
|
<SaveDraftButton />
|
||||||
{workflowStatus !== WorkflowStatus.ACTIVE && <ActivateButton />}
|
{workflowStatus !== WorkflowStatus.ACTIVE && <ActivateButton />}
|
||||||
{workflowStatus === WorkflowStatus.ACTIVE && <UpdateButton />}
|
{workflowStatus === WorkflowStatus.ACTIVE && <UpdateButton />}
|
||||||
|
Reference in New Issue
Block a user