Implement workflow error list popover in header

[MAILPOET-4659]
This commit is contained in:
Jan Jakes
2022-10-06 12:19:29 +02:00
committed by Jan Jakeš
parent 5be82a17ad
commit a085b33b62
4 changed files with 218 additions and 7 deletions

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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>
);
}

View File

@ -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 (
<Button isPrimary className="editor-post-publish-button" onClick={activate}>
<Button
isPrimary
className="editor-post-publish-button"
onClick={activate}
disabled={!!errors}
>
Activate
</Button>
);
@ -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 {
<div className="edit-site-header_end">
<div className="edit-site-header__actions">
{errors && Object.values(errors.steps).length > 0 && (
<Chip>{Object.values(errors.steps).length} issues</Chip>
)}
<Errors />
<SaveDraftButton />
{workflowStatus !== WorkflowStatus.ACTIVE && <ActivateButton />}
{workflowStatus === WorkflowStatus.ACTIVE && <UpdateButton />}