Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:31 +02:00
parent 52a51d29db
commit dfbb92752b
2 changed files with 56 additions and 241 deletions
@@ -18,18 +18,28 @@ import { DialogSection, ToolbarGroup, FormGrid, ActionToolbar,
ApiError, ApiError,
Button, Button,
ConfirmDialog, ConfirmDialog,
ContentSection,
DataGrid, DataGrid,
DataGridRowActions, DataGridRowActions,
Dialog, Dialog,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FilterBar,
FormField, FormField,
IconButton, IconButton,
LoadingFrame, LoadingFrame,
MetricCard,
MetricGrid,
SearchableSelect, SearchableSelect,
SegmentedControl, SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
WorkspaceFrame,
WorkspaceLayout,
formatDateTime, formatDateTime,
hasScope, hasScope,
useUnsavedChanges, useUnsavedChanges,
@@ -528,10 +538,18 @@ export default function DistributionListsPage({ settings, auth }: Props) {
} }
return ( return (
<main className="dist-lists-page"> <WorkspaceFrame as="main" height="viewport" surface="plain" className="dist-lists-page" label="Distribution lists workspace">
<div className="dist-lists-shell"> <WorkspaceLayout
<aside className="dist-lists-sidebar" aria-label="Distribution lists"> variant="split"
<ActionToolbar className="dist-lists-sidebar-toolbar"> primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Distribution lists"
contentLabel="Distribution list workspace"
contentClassName="dist-lists-workspace"
primary={<>
<ActionToolbar surface="panel-header">
<strong>Distribution Lists</strong> <strong>Distribution Lists</strong>
<span> <span>
<IconButton <IconButton
@@ -552,7 +570,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
/> />
</span> </span>
</ActionToolbar> </ActionToolbar>
<div className="dist-lists-search"> <FilterBar surface="panel">
<input <input
type="search" type="search"
value={search} value={search}
@@ -560,30 +578,25 @@ export default function DistributionListsPage({ settings, auth }: Props) {
placeholder="Search lists" placeholder="Search lists"
aria-label="Search distribution lists" aria-label="Search distribution lists"
/> />
</div> </FilterBar>
<LoadingFrame loading={loading} className="dist-lists-list-frame"> <LoadingFrame loading={loading} className="dist-lists-list-frame">
<div className="dist-lists-list"> <SelectionList variant="navigation" label="Distribution lists">
{visibleItems.map((item) => ( {visibleItems.map((item) => (
<button <SelectionListItem
key={item.id} key={item.id}
type="button" selected={item.id === selectedId}
className={item.id === selectedId ? "is-selected" : ""}
onClick={() => selectItem(item)} onClick={() => selectItem(item)}
> >
<span> <SelectionListItemContent title={item.name} description={`${item.revision.entries.length} entries · revision ${item.current_revision}`} />
<strong>{item.name}</strong>
<small>{item.revision.entries.length} entries · revision {item.current_revision}</small>
</span>
<StatusBadge status={item.revision.definition_kind} label={item.revision.definition_kind} /> <StatusBadge status={item.revision.definition_kind} label={item.revision.definition_kind} />
</button> </SelectionListItem>
))} ))}
{!visibleItems.length ? <div className="dist-lists-empty">No distribution lists</div> : null} {!visibleItems.length ? <StatePanel size="compact" description="No distribution lists" /> : null}
</div> </SelectionList>
</LoadingFrame> </LoadingFrame>
</aside> </>}
>
<section className="dist-lists-workspace"> <ActionToolbar surface="panel-header" className="dist-lists-workspace-toolbar">
<ActionToolbar className="dist-lists-workspace-toolbar">
<span className="dist-lists-current-title"> <span className="dist-lists-current-title">
<strong>{selected?.name ?? "No distribution list selected"}</strong> <strong>{selected?.name ?? "No distribution list selected"}</strong>
{selected ? <small>Revision {selected.current_revision} · {selected.scope_type} scope</small> : null} {selected ? <small>Revision {selected.current_revision} · {selected.scope_type} scope</small> : null}
@@ -626,7 +639,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
<LoadingFrame loading={loading} className="dist-lists-content-frame"> <LoadingFrame loading={loading} className="dist-lists-content-frame">
{!selected ? ( {!selected ? (
<div className="dist-lists-empty">Create or select a distribution list.</div> <StatePanel size="fill" title="Distribution lists" description="Create or select a distribution list." />
) : view === "definition" ? ( ) : view === "definition" ? (
<DefinitionEditor <DefinitionEditor
draft={draft} draft={draft}
@@ -671,12 +684,12 @@ export default function DistributionListsPage({ settings, auth }: Props) {
</section> </section>
{preview ? ( {preview ? (
<> <>
<div className="dist-lists-metrics"> <MetricGrid columns={4} density="compact" minimum="compact">
<Metric label="Included" value={preview.recipients.length} /> <MetricCard density="compact" label="Included" value={preview.recipients.length} />
<Metric label="Excluded" value={preview.excluded.length} /> <MetricCard density="compact" label="Excluded" value={preview.excluded.length} />
<Metric label="Providers" value={preview.provider_evidence.length} /> <MetricCard density="compact" label="Providers" value={preview.provider_evidence.length} />
<Metric label="Source state" value={preview.stale ? "Stale" : "Current"} /> <MetricCard density="compact" label="Source state" value={preview.stale ? "Stale" : "Current"} />
</div> </MetricGrid>
{preview.diagnostics.map((diagnostic, index) => ( {preview.diagnostics.map((diagnostic, index) => (
<DismissibleAlert <DismissibleAlert
key={`${diagnostic.code}-${index}`} key={`${diagnostic.code}-${index}`}
@@ -708,18 +721,18 @@ export default function DistributionListsPage({ settings, auth }: Props) {
/> />
</> </>
) : ( ) : (
<div className="dist-lists-empty">Expand the saved revision to inspect recipients and decisions.</div> <StatePanel size="default" description="Expand the saved revision to inspect recipients and decisions." />
)} )}
</div> </div>
) : ( ) : (
<div className="dist-lists-content"> <div className="dist-lists-content">
<div className="dist-lists-section-heading"> <ActionToolbar surface="section-header" className="dist-lists-section-heading">
<span> <span>
<strong>Frozen snapshots</strong> <strong>Frozen snapshots</strong>
<small>Immutable recipient evidence held by consumers</small> <small>Immutable recipient evidence held by consumers</small>
</span> </span>
<IconButton label="Refresh snapshots" icon={<RefreshCw size={16} />} variant="ghost" onClick={() => void openSnapshots()} /> <IconButton label="Refresh snapshots" icon={<RefreshCw size={16} />} variant="ghost" onClick={() => void openSnapshots()} />
</div> </ActionToolbar>
<DataGrid <DataGrid
id="distribution-list-snapshots" id="distribution-list-snapshots"
rows={snapshots} rows={snapshots}
@@ -731,8 +744,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
</div> </div>
)} )}
</LoadingFrame> </LoadingFrame>
</section> </WorkspaceLayout>
</div>
<Dialog <Dialog
open={createOpen} open={createOpen}
@@ -808,7 +820,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
onConfirm={() => void removeItem()} onConfirm={() => void removeItem()}
onCancel={() => setDeleteOpen(false)} onCancel={() => setDeleteOpen(false)}
/> />
</main> </WorkspaceFrame>
); );
} }
@@ -856,8 +868,8 @@ function DefinitionEditor({
</FormField> </FormField>
</section> </section>
<section className="dist-lists-section"> <ContentSection>
<div className="dist-lists-section-heading"> <ActionToolbar surface="section-header" className="dist-lists-section-heading">
<span> <span>
<strong>Parameters</strong> <strong>Parameters</strong>
<small>Typed values supplied by Campaign, Reporting, or Workflow</small> <small>Typed values supplied by Campaign, Reporting, or Workflow</small>
@@ -869,7 +881,7 @@ function DefinitionEditor({
> >
<Plus size={15} /> Add parameter <Plus size={15} /> Add parameter
</Button> </Button>
</div> </ActionToolbar>
<div className="dist-lists-parameter-list"> <div className="dist-lists-parameter-list">
{draft.parameters.map((parameter, index) => ( {draft.parameters.map((parameter, index) => (
<div className="dist-lists-parameter-row" key={`${parameter.key}-${index}`}> <div className="dist-lists-parameter-row" key={`${parameter.key}-${index}`}>
@@ -889,12 +901,12 @@ function DefinitionEditor({
<IconButton label="Remove parameter" icon={<Trash2 size={15} />} variant="ghost" disabled={!canWrite} onClick={() => onChange({ ...draft, parameters: draft.parameters.filter((_, itemIndex) => itemIndex !== index) })} /> <IconButton label="Remove parameter" icon={<Trash2 size={15} />} variant="ghost" disabled={!canWrite} onClick={() => onChange({ ...draft, parameters: draft.parameters.filter((_, itemIndex) => itemIndex !== index) })} />
</div> </div>
))} ))}
{!draft.parameters.length ? <div className="dist-lists-inline-empty">No parameters</div> : null} {!draft.parameters.length ? <StatePanel size="inline" description="No parameters" /> : null}
</div> </div>
</section> </ContentSection>
<section className="dist-lists-section dist-lists-entry-section"> <ContentSection className="dist-lists-entry-section">
<div className="dist-lists-section-heading"> <ActionToolbar surface="section-header" className="dist-lists-section-heading">
<span> <span>
<strong>Audience entries</strong> <strong>Audience entries</strong>
<small>Mixed local and provider-owned references; revisions are immutable after save</small> <small>Mixed local and provider-owned references; revisions are immutable after save</small>
@@ -902,7 +914,7 @@ function DefinitionEditor({
<Button variant="primary" disabled={!canWrite} onClick={onAddEntry}> <Button variant="primary" disabled={!canWrite} onClick={onAddEntry}>
<Plus size={15} /> Add entry <Plus size={15} /> Add entry
</Button> </Button>
</div> </ActionToolbar>
<DataGrid <DataGrid
id="distribution-list-entries" id="distribution-list-entries"
rows={draft.entries} rows={draft.entries}
@@ -911,7 +923,7 @@ function DefinitionEditor({
emptyText="No audience entries." emptyText="No audience entries."
className="dist-lists-grid" className="dist-lists-grid"
/> />
</section> </ContentSection>
</div> </div>
); );
} }
@@ -1144,10 +1156,6 @@ function ParameterValueField({ parameter, value, onChange }: { parameter: Distri
); );
} }
function Metric({ label, value }: { label: string; value: string | number }) {
return <span><small>{label}</small><strong>{value}</strong></span>;
}
function emptyPayload(): DistributionListPayload { function emptyPayload(): DistributionListPayload {
return { return {
name: "", name: "",
+1 -194
View File
@@ -1,34 +1,3 @@
.dist-lists-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.dist-lists-page *,
.dist-lists-page *::before,
.dist-lists-page *::after {
box-sizing: border-box;
}
.dist-lists-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-sidebar,
.dist-lists-workspace,
.dist-lists-list-frame, .dist-lists-list-frame,
.dist-lists-content-frame, .dist-lists-content-frame,
.dist-lists-content { .dist-lists-content {
@@ -36,32 +5,6 @@
min-height: 0; min-height: 0;
} }
.dist-lists-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.dist-lists-sidebar-toolbar,
.dist-lists-workspace-toolbar,
.dist-lists-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.dist-lists-sidebar-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.dist-lists-sidebar-toolbar > span,
.dist-lists-toolbar-actions, .dist-lists-toolbar-actions,
.dist-lists-row-actions, .dist-lists-row-actions,
.dist-lists-preview-actions, .dist-lists-preview-actions,
@@ -71,62 +14,11 @@
gap: 7px; gap: 7px;
} }
.dist-lists-search {
padding: 9px;
border-bottom: var(--border-line);
background: var(--panel);
}
.dist-lists-search input {
width: 100%;
min-height: 34px;
padding: 7px 9px;
}
.dist-lists-list-frame { .dist-lists-list-frame {
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
} }
.dist-lists-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.dist-lists-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
padding: 8px 9px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
}
.dist-lists-list > button:hover,
.dist-lists-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.dist-lists-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.dist-lists-list > button > span:first-child {
min-width: 0;
}
.dist-lists-list strong,
.dist-lists-list small,
.dist-lists-current-title strong, .dist-lists-current-title strong,
.dist-lists-current-title small, .dist-lists-current-title small,
.dist-lists-source-cell strong, .dist-lists-source-cell strong,
@@ -137,14 +29,12 @@
white-space: nowrap; white-space: nowrap;
} }
.dist-lists-list strong,
.dist-lists-current-title strong, .dist-lists-current-title strong,
.dist-lists-source-cell strong { .dist-lists-source-cell strong {
color: var(--text-strong); color: var(--text-strong);
font-size: 13px; font-size: 13px;
} }
.dist-lists-list small,
.dist-lists-current-title small, .dist-lists-current-title small,
.dist-lists-source-cell small { .dist-lists-source-cell small {
margin-top: 3px; margin-top: 3px;
@@ -152,16 +42,8 @@
font-size: 11px; font-size: 11px;
} }
.dist-lists-workspace {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.dist-lists-workspace-toolbar { .dist-lists-workspace-toolbar {
min-height: 58px; min-width: 0;
padding: 8px 10px 8px 14px;
} }
.dist-lists-current-title { .dist-lists-current-title {
@@ -202,17 +84,6 @@
padding: 14px; padding: 14px;
} }
.dist-lists-empty,
.dist-lists-inline-empty {
display: grid;
place-items: center;
min-height: 110px;
padding: 18px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.dist-lists-definition-fields { .dist-lists-definition-fields {
display: grid; display: grid;
grid-template-columns: minmax(220px, 1.4fr) minmax(160px, 0.7fr) minmax(160px, 0.7fr); grid-template-columns: minmax(220px, 1.4fr) minmax(160px, 0.7fr) minmax(160px, 0.7fr);
@@ -240,22 +111,10 @@
resize: vertical; resize: vertical;
} }
.dist-lists-section {
min-width: 0;
margin-bottom: 14px;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-entry-section { .dist-lists-entry-section {
min-height: 260px; min-height: 260px;
} }
.dist-lists-section-heading {
min-height: 44px;
padding: 7px 10px;
}
.dist-lists-section-heading > span { .dist-lists-section-heading > span {
min-width: 0; min-width: 0;
} }
@@ -323,39 +182,6 @@
padding-bottom: 1px; padding-bottom: 1px;
} }
.dist-lists-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.dist-lists-metrics > span {
min-height: 62px;
padding: 9px 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
}
.dist-lists-metrics small,
.dist-lists-metrics strong {
display: block;
}
.dist-lists-metrics small {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.dist-lists-metrics strong {
margin-top: 6px;
color: var(--text-strong);
font-size: 15px;
text-transform: capitalize;
}
.dist-lists-content > .alert { .dist-lists-content > .alert {
margin: 8px 0; margin: 8px 0;
} }
@@ -475,10 +301,6 @@
} }
@media (max-width: 1040px) { @media (max-width: 1040px) {
.dist-lists-shell {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
}
.dist-lists-workspace-toolbar { .dist-lists-workspace-toolbar {
align-items: flex-start; align-items: flex-start;
} }
@@ -505,20 +327,6 @@
overflow: auto; overflow: auto;
} }
.dist-lists-shell {
display: flex;
min-height: calc(100vh - 115px);
overflow: visible;
flex-direction: column;
}
.dist-lists-sidebar {
min-height: 230px;
max-height: 36vh;
border-right: 0;
border-bottom: var(--border-line);
}
.dist-lists-workspace { .dist-lists-workspace {
min-height: 560px; min-height: 560px;
} }
@@ -537,7 +345,6 @@
.dist-lists-definition-fields, .dist-lists-definition-fields,
.dist-lists-preview-inputs, .dist-lists-preview-inputs,
.dist-lists-metrics,
.dist-lists-channel-fieldset, .dist-lists-channel-fieldset,
.dist-lists-parameter-row { .dist-lists-parameter-row {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);