refactor(campaign): split review and recipient boundaries
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
|
||||
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import { humanize } from "../utils/campaignView";
|
||||
|
||||
export type FlowState =
|
||||
| "complete"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "active"
|
||||
| "locked"
|
||||
| "running"
|
||||
| "partial"
|
||||
| "stale"
|
||||
| "pending";
|
||||
|
||||
export type FlowStageDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
state: FlowState;
|
||||
connectorState?: FlowState;
|
||||
stateLabel: string;
|
||||
lockReason?: string;
|
||||
};
|
||||
|
||||
const stateColors: Record<FlowState, string> = {
|
||||
complete: "var(--green)",
|
||||
warning: "var(--amber)",
|
||||
danger: "var(--red)",
|
||||
active: "var(--blue)",
|
||||
locked: "var(--line-dark)",
|
||||
running: "var(--blue)",
|
||||
partial: "var(--review-flow-partial)",
|
||||
stale: "var(--review-flow-partial)",
|
||||
pending: "var(--muted)"
|
||||
};
|
||||
|
||||
export function WorkflowNavigation({
|
||||
stages,
|
||||
onSelect
|
||||
}: {
|
||||
stages: FlowStageDefinition[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
|
||||
<div className="review-flow-navigation-track">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stage.icon;
|
||||
const nextStage = stages[index + 1];
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
|
||||
const title = i18nMessage(stage.title);
|
||||
const shortTitle = i18nMessage(stage.shortTitle);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const style = {
|
||||
"--review-nav-color": stateColors[stage.state],
|
||||
"--review-nav-line-color": stateColors[connectorState],
|
||||
"--review-nav-line-next-color": stateColors[nextConnectorState]
|
||||
} as CSSProperties;
|
||||
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
||||
return (
|
||||
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-navigation-item"
|
||||
data-state={stage.state}
|
||||
onClick={() => onSelect(stage.id)}
|
||||
title={`${title}: ${stateText}`}
|
||||
>
|
||||
<span className="review-flow-navigation-icon">
|
||||
<Icon size={17} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="review-flow-navigation-copy">
|
||||
<strong>
|
||||
<span>{shortTitle}</span>
|
||||
{stage.state === "locked" && (
|
||||
<LockKeyhole size={12} aria-label="i18n:govoplan-campaign.locked.a798882f" />
|
||||
)}
|
||||
</strong>
|
||||
{showSecondaryState && <small>{stateText}</small>}
|
||||
</span>
|
||||
</button>
|
||||
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function stageConnectorState(stage: FlowStageDefinition): FlowState {
|
||||
return stage.connectorState ?? stage.state;
|
||||
}
|
||||
|
||||
export function WorkflowStage({
|
||||
stage,
|
||||
nextState,
|
||||
nextConnectorState,
|
||||
children
|
||||
}: {
|
||||
stage: FlowStageDefinition;
|
||||
nextState?: FlowState;
|
||||
nextConnectorState?: FlowState;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const Icon = stage.icon;
|
||||
const locked = stage.state === "locked";
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const title = i18nMessage(stage.title);
|
||||
const description = i18nMessage(stage.description);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const style = {
|
||||
"--review-stage-color": stateColors[stage.state],
|
||||
"--review-stage-line-color": stateColors[connectorState],
|
||||
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
|
||||
<div className="review-flow-stage-marker" aria-hidden="true">
|
||||
<div className="review-flow-stage-node">
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
</div>
|
||||
{nextState && <div className="review-flow-stage-line" />}
|
||||
</div>
|
||||
<article
|
||||
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
|
||||
aria-disabled={locked || undefined}
|
||||
>
|
||||
<header className="card-header review-flow-stage-header">
|
||||
<h2>
|
||||
<span>{title}</span>
|
||||
{locked && (
|
||||
<LockKeyhole
|
||||
className="review-flow-title-lock"
|
||||
size={15}
|
||||
aria-label="i18n:govoplan-campaign.locked.a798882f"
|
||||
/>
|
||||
)}
|
||||
{!locked && (
|
||||
<span
|
||||
className="review-flow-state-badge"
|
||||
data-state={stage.state}
|
||||
aria-label={stateText}
|
||||
title={stateText}
|
||||
>
|
||||
{stateText}
|
||||
</span>
|
||||
)}
|
||||
<InlineHelp>{description}</InlineHelp>
|
||||
</h2>
|
||||
<div className="review-flow-stage-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={
|
||||
collapsed
|
||||
? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title })
|
||||
: i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })
|
||||
}
|
||||
title={
|
||||
collapsed
|
||||
? "i18n:govoplan-campaign.show_content.0528d8d2"
|
||||
: "i18n:govoplan-campaign.show_header_only.24afefca"
|
||||
}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="card-body review-flow-stage-content">{children}</div>
|
||||
{locked && (
|
||||
<div className="review-flow-lock-message">
|
||||
<span className="review-flow-lock-icon">
|
||||
<LockKeyhole size={20} aria-hidden="true" />
|
||||
</span>
|
||||
<span>{lockReason}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="review-flow-fact">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function stateLabel(state: FlowState): string {
|
||||
switch (state) {
|
||||
case "complete":
|
||||
return "i18n:govoplan-campaign.passed.271d60f4";
|
||||
case "warning":
|
||||
return "i18n:govoplan-campaign.warnings.1430f976";
|
||||
case "danger":
|
||||
return "i18n:govoplan-campaign.blocked.99613c74";
|
||||
case "active":
|
||||
return "i18n:govoplan-campaign.available.7c62a142";
|
||||
case "locked":
|
||||
return "i18n:govoplan-campaign.locked.a798882f";
|
||||
case "running":
|
||||
return "i18n:govoplan-campaign.running.73989d9c";
|
||||
case "partial":
|
||||
return "i18n:govoplan-campaign.partial.65de2e2a";
|
||||
case "stale":
|
||||
return "i18n:govoplan-campaign.stale.189cc40c";
|
||||
default:
|
||||
return humanize(state);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user