Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:29 +02:00
parent 9a2f13bc9a
commit 06f773e4eb
5 changed files with 81 additions and 177 deletions
+53 -54
View File
@@ -38,7 +38,7 @@ import {
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { FormGrid, Button, Dialog, FormField, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { FormGrid, Button, Dialog, FormField, MetricCard, MetricGrid, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
@@ -80,7 +80,6 @@ import {
} from "./review/ReviewWorkflowGuidance";
import { calculateBuildReviewProgress } from "./review/reviewProgress";
import {
WorkflowFact,
WorkflowNavigation,
WorkflowStage,
stageConnectorState,
@@ -1398,12 +1397,12 @@ export default function ReviewSendPage({
<div className="review-flow-timeline" aria-label="i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b">
<WorkflowStage stage={stages[0]} nextState={stages[1].state} nextConnectorState={stageConnectorState(stages[1])}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
<WorkflowFact label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
</MetricGrid>
<ValidationWorkflowGuidance
errors={validationErrors}
warnings={validationWarnings}
@@ -1456,15 +1455,15 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[1]} nextState={stages[2].state} nextConnectorState={stageConnectorState(stages[2])}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</MetricGrid>
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
@@ -1559,12 +1558,12 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[2]} nextState={stages[3].state} nextConnectorState={stageConnectorState(stages[3])}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : mockResult ? mockMailboxMessages.length : "—"} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : mockResult ? mockMailboxMessages.length : "—"} />
</MetricGrid>
<div className="button-row compact-actions review-flow-stage-actions">
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
{busy === "mock" ? "i18n:govoplan-campaign.running_mock_delivery.06c6a3bf" : mockResult ? "i18n:govoplan-campaign.run_mock_delivery_again.77fa416e" : "i18n:govoplan-campaign.run_mock_delivery.b74ff33d"}
@@ -1616,19 +1615,19 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[3]} nextState={stages[4].state} nextConnectorState={stageConnectorState(stages[4])}>
<div className="review-flow-execution-summary">
<div><span>i18n:govoplan-campaign.recipients.78cbf8eb</span><strong>{jobsTotal || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.messages_to_send.f7763bf9</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.matched_attachments.ead1eeb1</span><strong>{matchedAttachments || "—"}</strong></div>
<div><span>i18n:govoplan-campaign.missing_ambiguous.fffdd3f5</span><strong>{missingAttachments} / {ambiguousAttachments}</strong></div>
<div><span>i18n:govoplan-campaign.rate_limit.d08e55f5</span><strong>{messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"}</strong></div>
<div><span>i18n:govoplan-campaign.minimum_duration.91a71a6d</span><strong>{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}</strong></div>
<div><span>i18n:govoplan-campaign.imap_append.8c0d9e96</span><strong>{Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"}</strong></div>
<div><span>i18n:govoplan-campaign.synchronous_limit.f88c0bcd</span><strong>{deliveryOptionsLoading ? "…" : synchronousSendLimit}</strong></div>
<div><span>i18n:govoplan-campaign.limit_source.bd933adb</span><strong>{deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))}</strong></div>
<div><span>i18n:govoplan-campaign.worker_queue.c911e32c</span><strong>{workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"}</strong></div>
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.recipients.78cbf8eb" value={jobsTotal || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.messages_to_send.f7763bf9" value={builtCount || jobsTotal || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.matched_attachments.ead1eeb1" value={matchedAttachments || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.missing_ambiguous.fffdd3f5" value={`${missingAttachments} / ${ambiguousAttachments}`} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.rate_limit.d08e55f5" value={messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.minimum_duration.91a71a6d" value={estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_append.8c0d9e96" value={Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.synchronous_limit.f88c0bcd" value={deliveryOptionsLoading ? "…" : synchronousSendLimit} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.limit_source.bd933adb" value={deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.worker_queue.c911e32c" value={workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.version.2da600bf" value={version ? `v${version.version_number}` : "—"} />
</MetricGrid>
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
<div className="review-send-controls">
<span>Approval gate</span>
@@ -1642,12 +1641,12 @@ export default function ReviewSendPage({
{approvalGateConfigured &&
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
</div>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
<WorkflowFact label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
</MetricGrid>
{queueStatusNote &&
<p className={`review-flow-inline-note ${queueStatusTone}`.trim()}>{queueStatusNote}</p>
}
@@ -1718,18 +1717,18 @@ export default function ReviewSendPage({
</WorkflowStage>
<WorkflowStage stage={stages[4]}>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
<WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
<WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
<WorkflowFact label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
<WorkflowFact label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
<WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
<WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
<WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
<WorkflowFact label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
<WorkflowFact label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
</div>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
</MetricGrid>
<div className="review-flow-result-line">
<StatusBadge status={deliveryDisplayStatus} />
<span>{deliveryStarted ? "i18n:govoplan-campaign.delivery_activity_is_available_in_the_report_and.cb163d1d" : "i18n:govoplan-campaign.no_real_delivery_has_started_for_this_campaign_v.3b9235ff"}</span>
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react";
import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
import {
ActionToolbar,
Button,
Dialog,
DismissibleAlert,
@@ -309,7 +310,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
return (
<section className="recipient-address-category" tabIndex={locked ? undefined : 0} onPaste={handlePaste}>
<div className="recipient-address-category-header">
<ActionToolbar surface="section-header" className="recipient-address-category-header">
<h3>{column.label}</h3>
<div className="recipient-address-category-controls">
{onMergeChange ?
@@ -322,7 +323,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
onChange={onMergeChange} /> :
null}
</div>
</div>
</ActionToolbar>
<div className="recipient-address-lines">
{column.allowMultiple && draftAddresses.length === 0 &&
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link2, X } from "lucide-react";
import { Button, Dialog, DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import { Button, Dialog, DismissibleAlert, MetricCard, MetricGrid, i18nMessage } from "@govoplan/core-webui";
import type {
CampaignAttachmentPreviewFile,
@@ -10,7 +10,6 @@ import {
attachmentPreviewLinkableFiles,
attachmentPreviewMatchedFiles
} from "../utils/attachmentPreview";
import { WorkflowFact } from "./WorkflowNavigation";
export default function AttachmentLinkingPreview({
preview,
@@ -70,8 +69,8 @@ export default function AttachmentLinkingPreview({
</Button>
</div>
</div>
<div className="review-flow-fact-grid">
<WorkflowFact
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.matched.1bf3ec5b"
value={
!loading && matchedFiles.length > 0 ? (
@@ -95,19 +94,19 @@ export default function AttachmentLinkingPreview({
)
}
/>
<WorkflowFact
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.linked.a089f600"
value={loading ? "..." : linkedCount || "—"}
/>
<WorkflowFact
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.need_link.fa4ab530"
value={loading ? "..." : unlinkedCount || "—"}
/>
<WorkflowFact
<MetricCard density="compact" surface="subtle"
label="i18n:govoplan-campaign.shared_source.7d4a1bf2"
value={loading ? "..." : preview?.shared_file_count ?? "—"}
/>
</div>
</MetricGrid>
{error && (
<DismissibleAlert tone="danger" compact dismissible={false}>
{error}
@@ -1,6 +1,7 @@
import { useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
import { type CSSProperties, type ReactNode } from "react";
import { LockKeyhole, type LucideIcon } from "lucide-react";
import {
Card,
InlineHelp,
StageRail,
i18nMessage,
@@ -107,7 +108,6 @@ export function WorkflowStage({
}) {
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);
@@ -127,11 +127,16 @@ export function WorkflowStage({
</div>
{nextState && <div className="review-flow-stage-line" />}
</div>
<article
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
<Card
as="article"
collapsible
persistCollapse={false}
className={`review-flow-stage-card${locked ? " is-locked" : ""}`}
headerClassName="review-flow-stage-header"
bodyClassName="review-flow-stage-content"
actionsClassName="review-flow-stage-header-actions"
aria-disabled={locked || undefined}
>
<header className="card-header review-flow-stage-header">
title={
<h2>
<span>{title}</span>
{locked && (
@@ -153,54 +158,22 @@ export function WorkflowStage({
)}
<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 && (
}
afterBody={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>
) : undefined}
>
{children}
</Card>
</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":