Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:31 +02:00
parent c16bb51aa3
commit 32a32c9906
2 changed files with 107 additions and 314 deletions
+106 -113
View File
@@ -23,14 +23,24 @@ import { FormGrid, DialogSection, ToolbarGroup, ActionToolbar,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
ConfirmDialog, ConfirmDialog,
ContentSection,
Dialog, Dialog,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FilterBar,
FormField, FormField,
IconButton, IconButton,
LoadingFrame, LoadingFrame,
MetricCard,
MetricGrid,
SegmentedControl, SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge, StatusBadge,
WorkspaceFrame,
WorkspaceLayout,
hasScope, hasScope,
isApiError, isApiError,
useUnsavedChanges, useUnsavedChanges,
@@ -284,10 +294,18 @@ export default function DatasourcesPage({
}; };
return ( return (
<main className="datasources-page"> <WorkspaceFrame as="main" height="viewport" surface="plain" className="datasources-page" label="Datasource workspace">
<div className="datasources-shell"> <WorkspaceLayout
<aside className="datasources-sidebar" aria-label="Datasource catalogue"> variant="split"
<ActionToolbar className="datasources-sidebar-toolbar"> primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Datasource catalogue"
contentLabel="Datasource workspace"
contentClassName="datasources-workspace"
primary={<>
<ActionToolbar surface="panel-header">
<strong>Data</strong> <strong>Data</strong>
<ToolbarGroup align="end" className="datasources-toolbar-actions"> <ToolbarGroup align="end" className="datasources-toolbar-actions">
<IconButton <IconButton
@@ -326,7 +344,7 @@ export default function DatasourcesPage({
}} }}
/> />
</div> </div>
<div className="datasources-search"> <FilterBar surface="panel">
<input <input
type="search" type="search"
value={search} value={search}
@@ -334,71 +352,56 @@ export default function DatasourcesPage({
placeholder={`Search ${view}`} placeholder={`Search ${view}`}
aria-label={`Search ${view}`} aria-label={`Search ${view}`}
/> />
</div> </FilterBar>
<LoadingFrame <LoadingFrame
loading={loading} loading={loading}
className="datasources-list-frame" className="datasources-list-frame"
> >
<div className="datasources-list"> <SelectionList variant="navigation" label="Datasources">
{view === "catalogue" ? visibleDatasources.map((item) => ( {view === "catalogue" ? visibleDatasources.map((item) => (
<button <SelectionListItem
key={item.ref} key={item.ref}
type="button" selected={item.ref === selectedDatasourceRef}
className={item.ref === selectedDatasourceRef ? "is-selected" : ""}
onClick={() => setSelectedDatasourceRef(item.ref)} onClick={() => setSelectedDatasourceRef(item.ref)}
> >
<span> <SelectionListItemContent title={item.name} description={`${item.source_name} · ${item.kind}`} />
<strong>{item.name}</strong>
<small>{item.source_name} · {item.kind}</small>
</span>
<StatusBadge status={item.mode} label={item.mode} /> <StatusBadge status={item.mode} label={item.mode} />
</button> </SelectionListItem>
)) : null} )) : null}
{view === "staging" ? visibleStages.map((item) => ( {view === "staging" ? visibleStages.map((item) => (
<button <SelectionListItem
key={item.ref} key={item.ref}
type="button" selected={item.ref === selectedStageRef}
className={item.ref === selectedStageRef ? "is-selected" : ""}
onClick={() => setSelectedStageRef(item.ref)} onClick={() => setSelectedStageRef(item.ref)}
> >
<span> <SelectionListItemContent title={item.name} description={`${item.source_name} · ${formatCount(item.row_count, "row")}`} />
<strong>{item.name}</strong>
<small>{item.source_name} · {formatCount(item.row_count, "row")}</small>
</span>
<StatusBadge status={item.state} label={item.state} /> <StatusBadge status={item.state} label={item.state} />
</button> </SelectionListItem>
)) : null} )) : null}
{view === "origins" ? visibleOrigins.map((item) => ( {view === "origins" ? visibleOrigins.map((item) => (
<button <SelectionListItem
key={item.ref} key={item.ref}
type="button" selected={item.ref === selectedOriginRef}
className={item.ref === selectedOriginRef ? "is-selected" : ""}
onClick={() => setSelectedOriginRef(item.ref)} onClick={() => setSelectedOriginRef(item.ref)}
> >
<span> <SelectionListItemContent title={item.name} description={`${item.provider} · ${item.kind}`} />
<strong>{item.name}</strong>
<small>{item.provider} · {item.kind}</small>
</span>
<StatusBadge status={item.shape} label={item.shape} /> <StatusBadge status={item.shape} label={item.shape} />
</button> </SelectionListItem>
)) : null} )) : null}
{view === "catalogue" && !visibleDatasources.length ? ( {view === "catalogue" && !visibleDatasources.length ? (
<div className="datasources-list-empty">No datasources</div> <StatePanel size="compact" description="No datasources" />
) : null} ) : null}
{view === "staging" && !visibleStages.length ? ( {view === "staging" && !visibleStages.length ? (
<div className="datasources-list-empty">No staged data</div> <StatePanel size="compact" description="No staged data" />
) : null} ) : null}
{view === "origins" && !visibleOrigins.length ? ( {view === "origins" && !visibleOrigins.length ? (
<div className="datasources-list-empty"> <StatePanel size="compact" description={originsAvailable ? "No connector origins" : "Connectors unavailable"} />
{originsAvailable ? "No connector origins" : "Connectors unavailable"}
</div>
) : null} ) : null}
</div> </SelectionList>
</LoadingFrame> </LoadingFrame>
</aside> </>}
>
<section className="datasources-workspace"> <ActionToolbar surface="panel-header" className="datasources-workspace-toolbar">
<ActionToolbar className="datasources-workspace-toolbar">
<span className="datasources-current-title"> <span className="datasources-current-title">
{view === "catalogue" ? <DatabaseZap size={19} /> {view === "catalogue" ? <DatabaseZap size={19} />
: view === "staging" ? <Layers3 size={19} /> : view === "staging" ? <Layers3 size={19} />
@@ -522,8 +525,7 @@ export default function DatasourcesPage({
) : null} ) : null}
</div> </div>
{working ? <div className="datasources-working" role="status">Working...</div> : null} {working ? <div className="datasources-working" role="status">Working...</div> : null}
</section> </WorkspaceLayout>
</div>
<AddDatasourceDialog <AddDatasourceDialog
open={addOpen} open={addOpen}
@@ -607,7 +609,7 @@ export default function DatasourcesPage({
onCancel={() => setRetireOpen(false)} onCancel={() => setRetireOpen(false)}
onConfirm={() => void retireSelected()} onConfirm={() => void retireSelected()}
/> />
</main> </WorkspaceFrame>
); );
} }
@@ -624,24 +626,24 @@ function DatasourceDetail({
}) { }) {
return ( return (
<> <>
<div className="datasources-metrics"> <MetricGrid columns={5} density="compact" minimum="compact">
<Metric label="Mode" value={datasource.mode} /> <MetricCard density="compact" label="Mode" value={datasource.mode} valueTitle={datasource.mode} />
<Metric label="Rows" value={formatNumber(datasource.row_count)} /> <MetricCard density="compact" label="Rows" value={formatNumber(datasource.row_count)} />
<Metric label="Fields" value={String(datasource.schema.length)} /> <MetricCard density="compact" label="Fields" value={String(datasource.schema.length)} />
<Metric label="Revisions" value={String(materializations.length)} /> <MetricCard density="compact" label="Revisions" value={String(materializations.length)} />
<Metric label="Updated" value={formatDate(datasource.updated_at)} /> <MetricCard density="compact" label="Updated" value={formatDate(datasource.updated_at)} />
</div> </MetricGrid>
{datasource.description ? ( {datasource.description ? (
<div className="datasources-description">{datasource.description}</div> <div className="datasources-description">{datasource.description}</div>
) : null} ) : null}
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span><ShieldCheck size={16} /> Governance</span> <span><ShieldCheck size={16} /> Governance</span>
<StatusBadge <StatusBadge
status={datasource.governance.publication_state} status={datasource.governance.publication_state}
label={datasource.governance.publication_state} label={datasource.governance.publication_state}
/> />
</div> </ActionToolbar>
<div className="datasources-key-values"> <div className="datasources-key-values">
<span><small>Authority</small><strong>{readableToken(datasource.governance.authority_mode)}</strong></span> <span><small>Authority</small><strong>{readableToken(datasource.governance.authority_mode)}</strong></span>
<span><small>Classification</small><strong>{datasource.governance.classification}</strong></span> <span><small>Classification</small><strong>{datasource.governance.classification}</strong></span>
@@ -653,25 +655,25 @@ function DatasourceDetail({
{datasource.governance.semantic_definition ? ( {datasource.governance.semantic_definition ? (
<p className="datasources-dialog-copy">{datasource.governance.semantic_definition}</p> <p className="datasources-dialog-copy">{datasource.governance.semantic_definition}</p>
) : null} ) : null}
</section> </ContentSection>
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span><Eye size={16} /> Preview</span> <span><Eye size={16} /> Preview</span>
<small>{preview ? `${formatNumber(preview.total_rows)} total rows` : ""}</small> <small>{preview ? `${formatNumber(preview.total_rows)} total rows` : ""}</small>
</div> </ActionToolbar>
{loading ? ( {loading ? (
<div className="datasources-inline-loading">Loading preview...</div> <div className="datasources-inline-loading">Loading preview...</div>
) : preview ? ( ) : preview ? (
<PreviewTable datasource={datasource} rows={preview.rows} /> <PreviewTable datasource={datasource} rows={preview.rows} />
) : ( ) : (
<div className="datasources-inline-empty">No preview available</div> <StatePanel size="inline" description="No preview available" />
)} )}
</section> </ContentSection>
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span><Archive size={16} /> Materializations</span> <span><Archive size={16} /> Materializations</span>
<small>Immutable revisions</small> <small>Immutable revisions</small>
</div> </ActionToolbar>
<div className="datasources-table-scroll"> <div className="datasources-table-scroll">
<table> <table>
<thead> <thead>
@@ -708,14 +710,14 @@ function DatasourceDetail({
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </ContentSection>
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Schema</span> <span>Schema</span>
<small>Version {datasource.schema_version}</small> <small>Version {datasource.schema_version}</small>
</div> </ActionToolbar>
<SchemaTable fields={datasource.schema} /> <SchemaTable fields={datasource.schema} />
</section> </ContentSection>
</> </>
); );
} }
@@ -727,21 +729,21 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
const schemaClassification = stage.validation.schema_change?.classification ?? "new"; const schemaClassification = stage.validation.schema_change?.classification ?? "new";
return ( return (
<> <>
<div className="datasources-metrics"> <MetricGrid columns={5} density="compact" minimum="compact">
<Metric label="State" value={stage.state} /> <MetricCard density="compact" label="State" value={stage.state} valueTitle={stage.state} />
<Metric label="Mode" value={stage.mode} /> <MetricCard density="compact" label="Mode" value={stage.mode} valueTitle={stage.mode} />
<Metric label="Rows" value={formatNumber(stage.row_count)} /> <MetricCard density="compact" label="Rows" value={formatNumber(stage.row_count)} />
<Metric label="Fields" value={String(stage.schema.length)} /> <MetricCard density="compact" label="Fields" value={String(stage.schema.length)} />
<Metric label="Created" value={formatDate(stage.created_at)} /> <MetricCard density="compact" label="Created" value={formatDate(stage.created_at)} />
</div> </MetricGrid>
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Validation</span> <span>Validation</span>
<StatusBadge <StatusBadge
status={stage.validation.valid === false ? "invalid" : "ready"} status={stage.validation.valid === false ? "invalid" : "ready"}
label={stage.validation.valid === false ? "Invalid" : "Ready"} label={stage.validation.valid === false ? "Invalid" : "Ready"}
/> />
</div> </ActionToolbar>
<div className="datasources-key-values"> <div className="datasources-key-values">
<span><small>Fingerprint</small><strong>{shortFingerprint(stage.fingerprint)}</strong></span> <span><small>Fingerprint</small><strong>{shortFingerprint(stage.fingerprint)}</strong></span>
<span><small>Target</small><strong>{stage.target_datasource_ref || "New datasource"}</strong></span> <span><small>Target</small><strong>{stage.target_datasource_ref || "New datasource"}</strong></span>
@@ -770,27 +772,27 @@ function StageDetail({ stage }: { stage: DatasourceStage }) {
All configured quality rules passed and no blocking schema change was detected. All configured quality rules passed and no blocking schema change was detected.
</div> </div>
)} )}
</section> </ContentSection>
{schemaChanges.length ? ( {schemaChanges.length ? (
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Schema comparison</span> <span>Schema comparison</span>
<StatusBadge status={schemaClassification} label={readableToken(schemaClassification)} /> <StatusBadge status={schemaClassification} label={readableToken(schemaClassification)} />
</div> </ActionToolbar>
<ul className="datasources-schema-changes"> <ul className="datasources-schema-changes">
{schemaChanges.map((change, index) => ( {schemaChanges.map((change, index) => (
<SchemaChangeItem key={`${change.code}-${change.field ?? index}`} change={change} /> <SchemaChangeItem key={`${change.code}-${change.field ?? index}`} change={change} />
))} ))}
</ul> </ul>
</section> </ContentSection>
) : null} ) : null}
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Detected schema</span> <span>Detected schema</span>
<small>{stage.shape}</small> <small>{stage.shape}</small>
</div> </ActionToolbar>
<SchemaTable fields={stage.schema} /> <SchemaTable fields={stage.schema} />
</section> </ContentSection>
</> </>
); );
} }
@@ -834,22 +836,22 @@ function SchemaChangeItem({ change }: { change: DatasourceSchemaChange }) {
function OriginDetail({ origin }: { origin: DatasourceOrigin }) { function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
return ( return (
<> <>
<div className="datasources-metrics"> <MetricGrid columns="auto" density="compact" minimum="compact">
<Metric label="Provider" value={origin.provider} /> <MetricCard density="compact" label="Provider" value={origin.provider} valueTitle={origin.provider} />
<Metric label="Mode" value={readableToken(origin.source_mode)} /> <MetricCard density="compact" label="Mode" value={readableToken(origin.source_mode)} />
<Metric label="Health" value={readableToken(origin.health.status)} /> <MetricCard density="compact" label="Health" value={readableToken(origin.health.status)} />
<Metric label="Rows" value={formatNumber(origin.row_count)} /> <MetricCard density="compact" label="Rows" value={formatNumber(origin.row_count)} />
<Metric label="Fields" value={String(origin.schema.length)} /> <MetricCard density="compact" label="Fields" value={String(origin.schema.length)} />
<Metric label="Updated" value={formatDate(origin.updated_at)} /> <MetricCard density="compact" label="Updated" value={formatDate(origin.updated_at)} />
</div> </MetricGrid>
{origin.description ? ( {origin.description ? (
<div className="datasources-description">{origin.description}</div> <div className="datasources-description">{origin.description}</div>
) : null} ) : null}
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Registration options</span> <span>Registration options</span>
<small>{origin.supported_modes.join(", ")}</small> <small>{origin.supported_modes.join(", ")}</small>
</div> </ActionToolbar>
<div className="datasources-key-values"> <div className="datasources-key-values">
<span><small>Origin reference</small><strong>{origin.ref}</strong></span> <span><small>Origin reference</small><strong>{origin.ref}</strong></span>
<span><small>Kind</small><strong>{readableToken(origin.kind)}</strong></span> <span><small>Kind</small><strong>{readableToken(origin.kind)}</strong></span>
@@ -858,14 +860,14 @@ function OriginDetail({ origin }: { origin: DatasourceOrigin }) {
<span><small>Health</small><strong>{origin.health.summary}</strong></span> <span><small>Health</small><strong>{origin.health.summary}</strong></span>
<span><small>Pushdown</small><strong>{pushdownSummary(origin)}</strong></span> <span><small>Pushdown</small><strong>{pushdownSummary(origin)}</strong></span>
</div> </div>
</section> </ContentSection>
<section className="datasources-detail-section"> <ContentSection>
<div className="datasources-section-heading"> <ActionToolbar surface="section-header" className="datasources-section-heading">
<span>Discovered schema</span> <span>Discovered schema</span>
<small>Version {origin.schema_version}</small> <small>Version {origin.schema_version}</small>
</div> </ActionToolbar>
<SchemaTable fields={origin.schema} /> <SchemaTable fields={origin.schema} />
</section> </ContentSection>
</> </>
); );
} }
@@ -1479,17 +1481,8 @@ function SchemaTable({ fields }: { fields: Datasource["schema"] }) {
); );
} }
function Metric({ label, value }: { label: string; value: string }) {
return (
<span>
<small>{label}</small>
<strong title={value}>{value}</strong>
</span>
);
}
function EmptyWorkspace({ icon, label }: { icon: React.ReactNode; label: string }) { function EmptyWorkspace({ icon, label }: { icon: React.ReactNode; label: string }) {
return <div className="datasources-workspace-empty">{icon}<strong>{label}</strong></div>; return <StatePanel size="fill" icon={icon} title={label} />;
} }
function filterItems<T>( function filterItems<T>(
+1 -201
View File
@@ -1,65 +1,9 @@
.datasources-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.datasources-page *,
.datasources-page *::before,
.datasources-page *::after {
box-sizing: border-box;
}
.datasources-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);
}
.datasources-sidebar,
.datasources-workspace,
.datasources-content, .datasources-content,
.datasources-list-frame { .datasources-list-frame {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
.datasources-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.datasources-sidebar-toolbar,
.datasources-workspace-toolbar,
.datasources-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);
}
.datasources-sidebar-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.datasources-toolbar-actions, .datasources-toolbar-actions,
.datasources-current-title, .datasources-current-title,
.datasources-current-title > span, .datasources-current-title > span,
@@ -75,80 +19,11 @@
background: var(--panel); background: var(--panel);
} }
.datasources-search {
padding: 9px;
border-bottom: var(--border-line);
}
.datasources-search input {
width: 100%;
min-height: 34px;
padding: 7px 9px;
}
.datasources-list-frame { .datasources-list-frame {
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
} }
.datasources-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.datasources-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;
}
.datasources-list > button:hover,
.datasources-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.datasources-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.datasources-list > button > span:first-child {
min-width: 0;
}
.datasources-list strong,
.datasources-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasources-list strong {
color: var(--text-strong);
font-size: 13px;
}
.datasources-list small {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.datasources-list-empty,
.datasources-inline-empty,
.datasources-inline-loading { .datasources-inline-loading {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -158,17 +33,8 @@
text-align: center; text-align: center;
} }
.datasources-workspace {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.datasources-workspace-toolbar { .datasources-workspace-toolbar {
min-height: 58px; min-width: 0;
padding: 8px 10px 8px 14px;
} }
.datasources-current-title { .datasources-current-title {
@@ -218,43 +84,6 @@
padding: 14px; padding: 14px;
} }
.datasources-metrics {
display: grid;
grid-template-columns: repeat(5, minmax(100px, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.datasources-metrics > span {
min-width: 0;
min-height: 61px;
padding: 9px 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
}
.datasources-metrics small,
.datasources-metrics strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasources-metrics small {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.datasources-metrics strong {
margin-top: 6px;
color: var(--text-strong);
font-size: 14px;
text-transform: capitalize;
}
.datasources-description { .datasources-description {
margin-bottom: 14px; margin-bottom: 14px;
padding: 10px 12px; padding: 10px 12px;
@@ -265,18 +94,6 @@
line-height: 1.45; line-height: 1.45;
} }
.datasources-detail-section {
min-width: 0;
margin-bottom: 14px;
border: var(--border-line);
background: var(--panel);
}
.datasources-section-heading {
min-height: 42px;
padding: 7px 10px;
}
.datasources-section-heading > span { .datasources-section-heading > span {
color: var(--text-strong); color: var(--text-strong);
font-size: 13px; font-size: 13px;
@@ -501,26 +318,10 @@
overflow: auto; overflow: auto;
} }
.datasources-shell {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
}
.datasources-sidebar {
max-height: 42vh;
border-right: 0;
border-bottom: var(--border-line);
}
.datasources-workspace { .datasources-workspace {
min-height: 58vh; min-height: 58vh;
} }
.datasources-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.datasources-workspace-toolbar { .datasources-workspace-toolbar {
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
@@ -532,7 +333,6 @@
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.datasources-metrics,
.datasources-key-values { .datasources-key-values {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }