Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:31 +02:00
parent f908ddbf9d
commit 20d134898a
6 changed files with 225 additions and 640 deletions
@@ -60,11 +60,14 @@ const checks = [
moduleEntry.indexOf("./styles/dataflow.css"),
"XyFlow base styles before GovOPlaN overrides"
],
[css.includes("height: calc(100vh - 115px)"), "full-height workspace"],
[
page.includes('<WorkspaceFrame as="main" height="viewport"'),
"central full-height workspace"
],
[css.includes(".dataflow-preview-table-wrap"), "bounded preview scrolling"],
[
/\.dataflow-palette-items\s*\{[^}]*grid-auto-rows:\s*max-content;[^}]*overflow-y:\s*auto;/s.test(css),
"bounded palette scrolling"
page.includes("<DefinitionPalette") && !css.includes(".dataflow-palette-items"),
"centralized bounded palette scrolling"
],
[
/\.dataflow-inspector-fields\s*\{[^}]*grid-auto-rows:\s*max-content;[^}]*overflow-y:\s*auto;/s.test(css),
@@ -14,6 +14,7 @@ import {
type Edge,
type ReactFlowInstance
} from "@xyflow/react";
import { StatePanel } from "@govoplan/core-webui";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type {
DataflowDiagnostic,
@@ -226,7 +227,7 @@ export default function DataflowCanvas({
return (
<div
ref={canvasRef}
className="dataflow-canvas"
className="definition-graph-canvas dataflow-canvas"
tabIndex={0}
aria-label="Dataflow graph canvas"
onDragOver={(event) => {
@@ -360,7 +361,7 @@ export default function DataflowCanvas({
<Controls showInteractive={false} />
</ReactFlow>
{!graph.nodes.length ? (
<div className="dataflow-canvas-empty">Drop a source here</div>
<StatePanel className="definition-graph-canvas-empty" size="fill" description="Drop a source here" />
) : null}
</div>
);
+6 -5
View File
@@ -1,4 +1,5 @@
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { CountBadge, DefinitionNodeIcon } from "@govoplan/core-webui";
import type { NodeTypeDefinition } from "../../api/dataflow";
import { dataflowNodeIcon } from "./nodeIcons";
@@ -31,7 +32,7 @@ export default function DataflowNode({ data, selected }: NodeProps<DataflowFlowN
id={port.id}
type="target"
position={Position.Left}
className="dataflow-node-handle dataflow-node-handle-input"
className="definition-node-handle dataflow-node-handle-input"
style={{ top: portPosition(index, data.definition.input_ports.length) }}
/>
))}
@@ -46,15 +47,15 @@ export default function DataflowNode({ data, selected }: NodeProps<DataflowFlowN
</span>
))
: null}
<span className="dataflow-node-icon" aria-hidden="true">
<DefinitionNodeIcon aria-hidden="true">
<Icon size={17} strokeWidth={1.8} />
</span>
</DefinitionNodeIcon>
<span className="dataflow-node-copy">
<strong>{data.label}</strong>
<small>{data.definition.label}</small>
</span>
{typeof data.outputRows === "number" ? (
<span className="dataflow-node-count">{data.outputRows}</span>
<CountBadge tone="neutral" size="compact">{data.outputRows}</CountBadge>
) : null}
{data.definition.output_ports.map((port, index) => (
<Handle
@@ -62,7 +63,7 @@ export default function DataflowNode({ data, selected }: NodeProps<DataflowFlowN
id={port.id}
type="source"
position={Position.Right}
className="dataflow-node-handle dataflow-node-handle-output"
className="definition-node-handle dataflow-node-handle-output"
style={{ top: portPosition(index, data.definition.output_ports.length) }}
/>
))}
+76 -72
View File
@@ -30,16 +30,29 @@ import { DialogSection, ToolbarGroup, ActionToolbar,
ActionBlockerHint,
Button,
ConfirmDialog,
ContentGrid,
ContentSection,
Dialog,
DismissibleAlert,
DefinitionPalette,
DefinitionPaletteGroup,
DefinitionPaletteItem,
DocumentationHelpLink,
FilterBar,
FloatingStatus,
FormField,
IconButton,
LoadingFrame,
ReferenceSelect,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
ToggleSwitch,
WorkspaceFrame,
WorkspaceLayout,
hasScope,
isApiError,
useUnsavedChanges,
@@ -584,10 +597,18 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
};
return (
<main className="dataflow-page">
<div className="dataflow-shell">
<aside className="dataflow-pipeline-panel" aria-label="Data pipelines">
<ActionToolbar className="dataflow-panel-toolbar">
<WorkspaceFrame as="main" height="viewport" surface="plain" className="dataflow-page" label="Dataflow workspace">
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Data pipelines"
contentLabel="Pipeline editor"
contentClassName="dataflow-workspace"
primary={<>
<ActionToolbar surface="panel-header">
<strong>Pipelines</strong>
<ToolbarGroup align="end" className="dataflow-toolbar-actions">
<IconButton
@@ -608,7 +629,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
/>
</ToolbarGroup>
</ActionToolbar>
<div className="dataflow-pipeline-search">
<FilterBar surface="panel">
<input
type="search"
value={search}
@@ -616,39 +637,32 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
placeholder="Search pipelines"
aria-label="Search pipelines"
/>
</div>
</FilterBar>
<LoadingFrame loading={loading} label="Loading pipelines" className="dataflow-pipeline-list-frame">
<div className="dataflow-pipeline-list">
<SelectionList variant="navigation" label="Data pipelines">
{filteredPipelines.map((pipeline) => (
<button
<SelectionListItem
key={pipeline.id}
type="button"
className={pipeline.id === draft?.id ? "is-selected" : ""}
selected={pipeline.id === draft?.id}
onClick={() => selectPipeline(pipeline)}
>
<span>
<strong>{pipeline.name}</strong>
<small>Revision {pipeline.current_revision}</small>
<small>
{pipeline.governance.scope_type} · {pipeline.governance.definition_kind}
</small>
</span>
<SelectionListItemContent
title={pipeline.name}
description={`Revision ${pipeline.current_revision} · ${pipeline.governance.scope_type} · ${pipeline.governance.definition_kind}`}
/>
<StatusBadge status={pipeline.status} />
</button>
</SelectionListItem>
))}
{!loading && !filteredPipelines.length ? (
<div className="dataflow-pipeline-empty">
{search ? "No matching pipelines" : "No pipelines yet"}
</div>
<StatePanel size="compact" description={search ? "No matching pipelines" : "No pipelines yet"} />
) : null}
</div>
</SelectionList>
</LoadingFrame>
</aside>
<section className="dataflow-workspace">
</>}
>
{draft ? (
<>
<ActionToolbar className="dataflow-workspace-toolbar">
<ActionToolbar surface="panel-header" className="dataflow-workspace-toolbar">
<div className="dataflow-identity-fields">
<input
className="dataflow-name-input"
@@ -835,10 +849,10 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
) : null}
<div className={`dataflow-editor ${draft.editorMode === "sql" ? "is-sql" : ""}`}>
{draft.editorMode === "graph" ? (
<aside className="dataflow-palette" aria-label="Transform palette">
<div className="dataflow-panel-heading">
<strong>Nodes</strong>
{canImportSources ? (
<DefinitionPalette
label="Nodes"
aria-label="Transform palette"
actions={canImportSources ? (
<IconButton
label="Stage datasource"
icon={<Upload size={15} />}
@@ -846,36 +860,30 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
onClick={() => setSnapshotOpen(true)}
/>
) : null}
</div>
<div className="dataflow-palette-items">
>
{paletteGroups.map((group) => (
<section key={group.category} className="dataflow-palette-group">
<h3>{group.label}</h3>
<DefinitionPaletteGroup key={group.category} label={group.label}>
{group.nodes.map((definition) => {
const Icon = dataflowNodeIcon(definition.icon);
const disabled = !canEdit || uniqueNodeExists(draft, definition.type);
return (
<button
<DefinitionPaletteItem
key={definition.type}
type="button"
title={definition.description}
icon={<Icon size={16} />}
label={definition.label}
draggable={!disabled}
disabled={disabled}
onDragStart={(event) => startPaletteDrag(event, definition.type)}
onClick={() => addNode(definition.type)}
>
<Icon size={16} />
<span>{definition.label}</span>
<Plus size={14} className="dataflow-palette-add" />
</button>
/>
);
})}
</section>
</DefinitionPaletteGroup>
))}
</div>
</aside>
</DefinitionPalette>
) : null}
<section className="dataflow-editor-surface">
<section className="definition-editor-surface dataflow-editor-surface">
{draft.editorMode === "graph" ? (
<ReactFlowProvider>
<DataflowCanvas
@@ -891,7 +899,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
</ReactFlowProvider>
) : (
<div className="dataflow-sql-workbench">
<ActionToolbar className="dataflow-sql-toolbar">
<ActionToolbar surface="section-header" className="dataflow-sql-toolbar">
<span>Constrained Dataflow SQL</span>
<Button
variant="primary"
@@ -955,22 +963,22 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
) : null}
</>
) : (
<div className="dataflow-workspace-empty">
<Network size={30} />
<strong>No pipeline selected</strong>
<Button
<StatePanel
size="fill"
icon={<Network size={30} />}
title="No pipeline selected"
actions={<Button
variant="primary"
onClick={createNew}
disabled={!canWrite}
disabledReason={!canWrite ? DATAFLOW_I18N.writeReason : undefined}
>
<Plus size={16} /> New pipeline
</Button>
</div>
</Button>}
/>
)}
{working ? <div className="dataflow-working-indicator" role="status">Working...</div> : null}
</section>
</div>
{working ? <FloatingStatus>Working...</FloatingStatus> : null}
</WorkspaceLayout>
<ConfirmDialog
open={deleteOpen}
title="Delete pipeline"
@@ -1102,7 +1110,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
}).catch((sourceError) => setError(apiErrorMessage(sourceError)));
}}
/>
</main>
</WorkspaceFrame>
);
}
@@ -1207,7 +1215,7 @@ function DefinitionSettingsDialog({
<option value="template">Template</option>
</select>
</FormField>
<div className="dataflow-definition-toggles">
<ContentGrid columns={2} gap="default" collapseAt="narrow">
<ToggleSwitch
label="Visible to lower scopes"
checked={draft.inheritToLowerScopes}
@@ -1232,9 +1240,9 @@ function DefinitionSettingsDialog({
disabled={!editable}
onChange={(value) => onChange({ allowAutomation: value })}
/>
</div>
</ContentGrid>
{draft.governance?.derived_from_pipeline_id ? (
<section className="dataflow-provenance">
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="dataflow-provenance">
<strong>Derived from</strong>
<span>
{draft.governance.derived_from_pipeline_id}
@@ -1242,10 +1250,10 @@ function DefinitionSettingsDialog({
{draft.governance.derived_from_revision}
</span>
<code>{draft.governance.derived_from_hash}</code>
</section>
</ContentSection>
) : null}
{provenance.length ? (
<section className="dataflow-provenance">
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="dataflow-provenance">
<strong>Effective Policy path</strong>
{provenance.map((item, index) => (
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
@@ -1255,7 +1263,7 @@ function DefinitionSettingsDialog({
{!editable && draft.governance?.actions.edit?.reason ? (
<small>{draft.governance.actions.edit.reason}</small>
) : null}
</section>
</ContentSection>
) : null}
</div>
) : null}
@@ -2745,9 +2753,7 @@ function ReconciliationDecisionDialog({
</div>
</ActionToolbar>
{!rows.length ? (
<div className="dataflow-results-empty">
Run a preview of a reconciliation comparison that emits stable key and input hashes.
</div>
<StatePanel size="compact" description="Run a preview of a reconciliation comparison that emits stable key and input hashes." />
) : (
<div className="dataflow-decision-layout">
<div className="dataflow-decision-rows" role="listbox" aria-label="Reconciliation rows">
@@ -2844,9 +2850,7 @@ function ReconciliationDecisionDialog({
</div>
</>
) : (
<div className="dataflow-results-empty">
{loading ? "Loading decision sets..." : "Create or select a decision set to review this row."}
</div>
<StatePanel size="compact" description={loading ? "Loading decision sets..." : "Create or select a decision set to review this row."} />
)}
</div>
</div>
@@ -2885,7 +2889,7 @@ function ResultPanel({
const previewRows = preview?.node_preview?.total_rows ?? preview?.total_rows;
return (
<section className="dataflow-results" aria-label="Pipeline results">
<ActionToolbar className="dataflow-results-toolbar">
<ActionToolbar surface="section-header" className="dataflow-results-toolbar">
<div className="dataflow-results-view-controls">
<SegmentedControl<ResultTab>
ariaLabel="Result view"
@@ -2926,9 +2930,9 @@ function ResultPanel({
}
function PreviewTable({ preview }: { preview: PipelinePreview | null }) {
if (!preview) return <div className="dataflow-results-empty">No preview has been run.</div>;
if (!preview) return <StatePanel size="compact" description="No preview has been run." />;
if (preview.status === "failed" && !preview.node_preview) {
return <div className="dataflow-results-empty">Preview failed.</div>;
return <StatePanel size="compact" tone="danger" description="Preview failed." />;
}
const result = preview.node_preview ?? preview;
return (
@@ -2978,7 +2982,7 @@ function DiagnosticsPanel({
nodeDiagnostics: NodePreviewDiagnostic[];
}) {
if (!diagnostics.length && !nodeDiagnostics.length) {
return <div className="dataflow-results-empty">No diagnostics.</div>;
return <StatePanel size="compact" description="No diagnostics." />;
}
return (
<div className="dataflow-diagnostics-list">
+131 -129
View File
@@ -1,9 +1,11 @@
import { useEffect, useState, type ComponentProps } from "react";
import { Play, Trash2 } from "lucide-react";
import {
ActionToolbar,
DismissibleAlert,
FormField as CoreFormField,
IconButton
IconButton,
StatePanel
} from "@govoplan/core-webui";
import type {
NodeTypeDefinition,
@@ -14,7 +16,7 @@ import { DATAFLOW_NODE_DOCUMENTATION } from "./interfacePatterns";
type NodeFormFieldProps = ComponentProps<typeof CoreFormField>;
function FormField({ documentation, ...props }: NodeFormFieldProps) {
function NodeFormField({ documentation, ...props }: NodeFormFieldProps) {
return (
<CoreFormField
{...props}
@@ -73,10 +75,10 @@ export default function NodeInspector({
if (!node) {
return (
<aside className="dataflow-inspector" aria-label="Node inspector">
<div className="dataflow-panel-heading">
<ActionToolbar surface="section-header" className="dataflow-panel-heading">
<strong>Inspector</strong>
</div>
<div className="dataflow-inspector-empty">No node selected</div>
</ActionToolbar>
<StatePanel size="compact" description="No node selected" />
</aside>
);
}
@@ -161,7 +163,7 @@ export default function NodeInspector({
return (
<aside className="dataflow-inspector" aria-label="Node inspector">
<div className="dataflow-panel-heading">
<ActionToolbar surface="section-header" className="dataflow-panel-heading">
<span>
<strong>Inspector</strong>
<small>{definition?.label ?? node.type}</small>
@@ -182,32 +184,32 @@ export default function NodeInspector({
disabled={readOnly}
/>
</span>
</div>
</ActionToolbar>
<div className="dataflow-inspector-fields">
{localError ? (
<DismissibleAlert tone="danger" resetKey={localError}>
{localError}
</DismissibleAlert>
) : null}
<FormField label="Name">
<NodeFormField label="Name">
<input
value={node.label}
onChange={(event) => onChange({ ...node, label: event.target.value })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
{node.type.startsWith("source.") ? (
<FormField label="Logical source name">
<NodeFormField label="Logical source name">
<input
value={textValue(node.config.source_name)}
onChange={(event) => updateConfig({ source_name: event.target.value })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "source.reference" ? (
<>
<FormField label="Source">
<NodeFormField label="Source">
<select
value={textValue(node.config.source_ref)}
onChange={(event) => {
@@ -237,8 +239,8 @@ export default function NodeInspector({
</option>
))}
</select>
</FormField>
<FormField label="State">
</NodeFormField>
<NodeFormField label="State">
<select
value={textValue(node.config.consistency) || "current"}
onChange={(event) => updateConfig({ consistency: event.target.value })}
@@ -248,20 +250,20 @@ export default function NodeInspector({
<option value="live">Live</option>
<option value="frozen">Latest frozen</option>
</select>
</FormField>
</NodeFormField>
{textValue(node.config.expected_fingerprint) ? (
<FormField label="Pinned fingerprint">
<NodeFormField label="Pinned fingerprint">
<input
value={textValue(node.config.expected_fingerprint)}
readOnly
title={textValue(node.config.expected_fingerprint)}
/>
</FormField>
</NodeFormField>
) : null}
</>
) : null}
{node.type === "source.inline" ? (
<FormField label="Rows">
<NodeFormField label="Rows">
<textarea
className="dataflow-json-editor"
value={rowsText}
@@ -270,18 +272,18 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "filter" ? (
<>
<FormField label="Column">
<NodeFormField label="Column">
<input
value={textValue(node.config.column)}
onChange={(event) => updateConfig({ column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Operator">
</NodeFormField>
<NodeFormField label="Operator">
<select
value={textValue(node.config.operator) || "eq"}
onChange={(event) => updateConfig({ operator: event.target.value })}
@@ -297,20 +299,20 @@ export default function NodeInspector({
<option value="is_null">is null</option>
<option value="not_null">is not null</option>
</select>
</FormField>
</NodeFormField>
{!["is_null", "not_null"].includes(textValue(node.config.operator)) ? (
<FormField label="Value">
<NodeFormField label="Value">
<input
value={displayScalar(node.config.value)}
onChange={(event) => updateConfig({ value: parseScalar(event.target.value) })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
</>
) : null}
{node.type === "filter.expression" ? (
<FormField label="Expression">
<NodeFormField label="Expression">
<textarea
className="dataflow-expression-editor"
value={textValue(node.config.expression)}
@@ -318,20 +320,20 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "distinct" ? (
<FormField label="Key columns">
<NodeFormField label="Key columns">
<input
value={stringList(node.config.columns).join(", ")}
onChange={(event) => updateConfig({ columns: commaList(event.target.value) })}
placeholder="All columns"
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "combine.union" ? (
<FormField label="Duplicates">
<NodeFormField label="Duplicates">
<select
value={textValue(node.config.mode) || "all"}
onChange={(event) => updateConfig({ mode: event.target.value })}
@@ -340,11 +342,11 @@ export default function NodeInspector({
<option value="all">Keep all rows</option>
<option value="distinct">Remove duplicates</option>
</select>
</FormField>
</NodeFormField>
) : null}
{node.type === "combine.join" ? (
<>
<FormField label="Join type">
<NodeFormField label="Join type">
<select
value={textValue(node.config.join_type) || "inner"}
onChange={(event) => updateConfig({ join_type: event.target.value })}
@@ -357,51 +359,51 @@ export default function NodeInspector({
<option value="semi">Left rows with a match</option>
<option value="anti">Left rows without a match</option>
</select>
</FormField>
<FormField label="Left keys">
</NodeFormField>
<NodeFormField label="Left keys">
<input
value={stringList(node.config.left_keys).join(", ")}
onChange={(event) => updateConfig({ left_keys: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
<FormField label="Right keys">
</NodeFormField>
<NodeFormField label="Right keys">
<input
value={stringList(node.config.right_keys).join(", ")}
onChange={(event) => updateConfig({ right_keys: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
{!["semi", "anti"].includes(textValue(node.config.join_type)) ? (
<FormField label="Right-column prefix">
<NodeFormField label="Right-column prefix">
<input
value={textValue(node.config.right_prefix)}
onChange={(event) => updateConfig({ right_prefix: event.target.value })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
</>
) : null}
{node.type === "select" ? (
<FormField label="Columns">
<NodeFormField label="Columns">
<input
value={selectFieldsToText(node.config.fields)}
onChange={(event) => updateConfig({ fields: selectFieldsFromText(event.target.value) })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "aggregate" ? (
<>
<FormField label="Group by">
<NodeFormField label="Group by">
<input
value={stringList(node.config.group_by).join(", ")}
onChange={(event) => updateConfig({ group_by: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
<FormField label="Aggregates">
</NodeFormField>
<NodeFormField label="Aggregates">
<textarea
className="dataflow-expression-editor"
value={aggregateText}
@@ -410,19 +412,19 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "derive" ? (
<>
<FormField label="Output column">
<NodeFormField label="Output column">
<input
value={textValue(node.config.target_column)}
onChange={(event) => updateConfig({ target_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Operation">
</NodeFormField>
<NodeFormField label="Operation">
<select
value={textValue(node.config.operation) || "copy"}
onChange={(event) => updateConfig({ operation: event.target.value })}
@@ -439,35 +441,35 @@ export default function NodeInspector({
<option value="multiply">Multiply</option>
<option value="divide">Divide</option>
</select>
</FormField>
<FormField label="Source columns">
</NodeFormField>
<NodeFormField label="Source columns">
<input
value={stringList(node.config.source_columns).join(", ")}
onChange={(event) => updateConfig({ source_columns: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
{textValue(node.config.operation) === "concat" ? (
<FormField label="Separator">
<NodeFormField label="Separator">
<input
value={textValue(node.config.separator)}
onChange={(event) => updateConfig({ separator: event.target.value })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
</>
) : null}
{node.type === "expression" ? (
<>
<FormField label="Output column">
<NodeFormField label="Output column">
<input
value={textValue(node.config.target_column)}
onChange={(event) => updateConfig({ target_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Expression">
</NodeFormField>
<NodeFormField label="Expression">
<textarea
className="dataflow-expression-editor"
value={textValue(node.config.expression)}
@@ -475,8 +477,8 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
<FormField label="Expected type">
</NodeFormField>
<NodeFormField label="Expected type">
<select
value={textValue(node.config.result_type) || "unknown"}
onChange={(event) => updateConfig({ result_type: event.target.value })}
@@ -490,11 +492,11 @@ export default function NodeInspector({
<option value="date">Date</option>
<option value="datetime">Date and time</option>
</select>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "calculate" ? (
<FormField label="Calculated columns">
<NodeFormField label="Calculated columns">
<textarea
className="dataflow-expression-editor"
value={calculationText}
@@ -503,25 +505,25 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "convert" ? (
<>
<FormField label="Source column">
<NodeFormField label="Source column">
<input
value={textValue(node.config.source_column)}
onChange={(event) => updateConfig({ source_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Output column">
</NodeFormField>
<NodeFormField label="Output column">
<input
value={textValue(node.config.target_column)}
onChange={(event) => updateConfig({ target_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Data type">
</NodeFormField>
<NodeFormField label="Data type">
<select
value={textValue(node.config.target_type) || "string"}
onChange={(event) => updateConfig({ target_type: event.target.value })}
@@ -534,8 +536,8 @@ export default function NodeInspector({
<option value="date">Date</option>
<option value="datetime">Date and time</option>
</select>
</FormField>
<FormField label="Conversion error">
</NodeFormField>
<NodeFormField label="Conversion error">
<select
value={textValue(node.config.on_error) || "fail"}
onChange={(event) => updateConfig({ on_error: event.target.value })}
@@ -545,26 +547,26 @@ export default function NodeInspector({
<option value="null">Use null</option>
<option value="keep">Keep original</option>
</select>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "replace" ? (
<>
<FormField label="Source column">
<NodeFormField label="Source column">
<input
value={textValue(node.config.source_column)}
onChange={(event) => updateConfig({ source_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Output column">
</NodeFormField>
<NodeFormField label="Output column">
<input
value={textValue(node.config.target_column)}
onChange={(event) => updateConfig({ target_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Mode">
</NodeFormField>
<NodeFormField label="Mode">
<select
value={textValue(node.config.mode) || "exact"}
onChange={(event) => updateConfig({ mode: event.target.value })}
@@ -573,25 +575,25 @@ export default function NodeInspector({
<option value="exact">Exact value</option>
<option value="text">Text fragment</option>
</select>
</FormField>
<FormField label="Find">
</NodeFormField>
<NodeFormField label="Find">
<input
value={displayScalar(node.config.find)}
onChange={(event) => updateConfig({ find: parseScalar(event.target.value) })}
disabled={readOnly}
/>
</FormField>
<FormField label="Replacement">
</NodeFormField>
<NodeFormField label="Replacement">
<input
value={displayScalar(node.config.replacement)}
onChange={(event) => updateConfig({ replacement: parseScalar(event.target.value) })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "sort" ? (
<FormField label="Sort fields">
<NodeFormField label="Sort fields">
<textarea
className="dataflow-expression-editor"
value={sortText}
@@ -600,11 +602,11 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "window.rank" ? (
<>
<FormField label="Method">
<NodeFormField label="Method">
<select
value={textValue(node.config.method) || "row_number"}
onChange={(event) => updateConfig({ method: event.target.value })}
@@ -614,22 +616,22 @@ export default function NodeInspector({
<option value="rank">Rank with gaps</option>
<option value="dense_rank">Dense rank</option>
</select>
</FormField>
<FormField label="Output column">
</NodeFormField>
<NodeFormField label="Output column">
<input
value={textValue(node.config.target_column)}
onChange={(event) => updateConfig({ target_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Partition by">
</NodeFormField>
<NodeFormField label="Partition by">
<input
value={stringList(node.config.partition_by).join(", ")}
onChange={(event) => updateConfig({ partition_by: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
<FormField label="Order by">
</NodeFormField>
<NodeFormField label="Order by">
<textarea
className="dataflow-expression-editor"
value={rankSortText}
@@ -638,11 +640,11 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "limit" ? (
<FormField label="Maximum rows">
<NodeFormField label="Maximum rows">
<input
type="number"
min={1}
@@ -651,11 +653,11 @@ export default function NodeInspector({
onChange={(event) => updateConfig({ count: Number(event.target.value) })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
) : null}
{node.type === "quality.rules" ? (
<>
<FormField label="Rules">
<NodeFormField label="Rules">
<textarea
className="dataflow-json-editor"
value={rulesText}
@@ -664,8 +666,8 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
<FormField label="Invalid rows">
</NodeFormField>
<NodeFormField label="Invalid rows">
<select
value={textValue(node.config.action) || "annotate"}
onChange={(event) => updateConfig({ action: event.target.value })}
@@ -675,26 +677,26 @@ export default function NodeInspector({
<option value="drop">Drop</option>
<option value="fail">Stop</option>
</select>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "reconcile.compare" ? (
<>
<FormField label="Expected keys">
<NodeFormField label="Expected keys">
<input
value={stringList(node.config.left_keys).join(", ")}
onChange={(event) => updateConfig({ left_keys: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
<FormField label="Observed keys">
</NodeFormField>
<NodeFormField label="Observed keys">
<input
value={stringList(node.config.right_keys).join(", ")}
onChange={(event) => updateConfig({ right_keys: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
<FormField label="Compared columns">
</NodeFormField>
<NodeFormField label="Compared columns">
<input
value={comparisonFieldsToText(node.config.compare_columns)}
onChange={(event) => updateConfig({
@@ -702,100 +704,100 @@ export default function NodeInspector({
})}
disabled={readOnly}
/>
</FormField>
<FormField label="Observed prefix">
</NodeFormField>
<NodeFormField label="Observed prefix">
<input
value={textValue(node.config.right_prefix)}
onChange={(event) => updateConfig({ right_prefix: event.target.value })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "reconcile.decisions" ? (
<>
<FormField label="Decision key hash column">
<NodeFormField label="Decision key hash column">
<input
value={textValue(node.config.decision_key_column)}
onChange={(event) => updateConfig({ decision_key_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Decision input hash column">
</NodeFormField>
<NodeFormField label="Decision input hash column">
<input
value={textValue(node.config.decision_input_column)}
onChange={(event) => updateConfig({ decision_input_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Decision reference column">
</NodeFormField>
<NodeFormField label="Decision reference column">
<input
value={textValue(node.config.decision_ref_column)}
onChange={(event) => updateConfig({ decision_ref_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Action column">
</NodeFormField>
<NodeFormField label="Action column">
<input
value={textValue(node.config.action_column)}
onChange={(event) => updateConfig({ action_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Actor reference column">
</NodeFormField>
<NodeFormField label="Actor reference column">
<input
value={textValue(node.config.actor_column)}
onChange={(event) => updateConfig({ actor_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Decision time column">
</NodeFormField>
<NodeFormField label="Decision time column">
<input
value={textValue(node.config.decided_at_column)}
onChange={(event) => updateConfig({ decided_at_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Reason column">
</NodeFormField>
<NodeFormField label="Reason column">
<input
value={textValue(node.config.reason_column)}
onChange={(event) => updateConfig({ reason_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Correction column">
</NodeFormField>
<NodeFormField label="Correction column">
<input
value={textValue(node.config.correction_column)}
onChange={(event) => updateConfig({ correction_column: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Allowed actions">
</NodeFormField>
<NodeFormField label="Allowed actions">
<input
value={stringList(node.config.allowed_actions).join(", ")}
onChange={(event) => updateConfig({ allowed_actions: commaList(event.target.value) })}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
</>
) : null}
{node.type === "subflow" ? (
<>
<FormField label="Template reference">
<NodeFormField label="Template reference">
<input
value={textValue(node.config.template_ref)}
onChange={(event) => updateConfig({ template_ref: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Template version">
</NodeFormField>
<NodeFormField label="Template version">
<input
value={textValue(node.config.template_version)}
onChange={(event) => updateConfig({ template_version: event.target.value })}
disabled={readOnly}
/>
</FormField>
<FormField label="Parameters">
</NodeFormField>
<NodeFormField label="Parameters">
<textarea
className="dataflow-json-editor"
value={parametersText}
@@ -804,8 +806,8 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
<FormField label="Pinned graph">
</NodeFormField>
<NodeFormField label="Pinned graph">
<textarea
className="dataflow-json-editor"
value={subflowGraphText}
@@ -814,7 +816,7 @@ export default function NodeInspector({
spellCheck={false}
disabled={readOnly}
/>
</FormField>
</NodeFormField>
</>
) : null}
</div>
+3 -429
View File
@@ -1,69 +1,10 @@
.dataflow-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.dataflow-page *,
.dataflow-page *::before,
.dataflow-page *::after {
box-sizing: border-box;
}
.dataflow-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);
}
.dataflow-pipeline-panel,
.dataflow-workspace,
.dataflow-editor,
.dataflow-editor-surface,
.dataflow-canvas,
.dataflow-pipeline-list-frame {
min-width: 0;
min-height: 0;
}
.dataflow-pipeline-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.dataflow-panel-toolbar,
.dataflow-workspace-toolbar,
.dataflow-results-toolbar,
.dataflow-sql-toolbar,
.dataflow-panel-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);
}
.dataflow-panel-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.dataflow-toolbar-actions,
.dataflow-command-bar,
.dataflow-identity-fields {
@@ -72,66 +13,11 @@
gap: 7px;
}
.dataflow-pipeline-search {
padding: 10px;
border-bottom: var(--border-line);
}
.dataflow-pipeline-search input {
min-height: 34px;
padding: 7px 9px;
}
.dataflow-pipeline-list-frame {
flex: 1 1 auto;
overflow: hidden;
}
.dataflow-pipeline-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.dataflow-pipeline-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.dataflow-pipeline-list > button:hover,
.dataflow-pipeline-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.dataflow-pipeline-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.dataflow-pipeline-list > button > span:first-child {
min-width: 0;
}
.dataflow-pipeline-list strong,
.dataflow-pipeline-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dataflow-definition-dialog {
width: min(620px, calc(100vw - 32px));
}
@@ -150,13 +36,6 @@
width: 100%;
}
.dataflow-definition-toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.dataflow-provenance,
.dataflow-trigger-security {
display: grid;
gap: 6px;
@@ -241,8 +120,7 @@
}
@media (max-width: 760px) {
.dataflow-triggers-layout,
.dataflow-definition-toggles {
.dataflow-triggers-layout {
grid-template-columns: 1fr;
}
@@ -254,49 +132,12 @@
}
}
.dataflow-pipeline-list strong {
color: var(--text-strong);
font-size: 13px;
}
.dataflow-pipeline-list small {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.dataflow-pipeline-list .status-badge {
max-width: 74px;
overflow: hidden;
text-overflow: ellipsis;
}
.dataflow-pipeline-empty,
.dataflow-inspector-empty,
.dataflow-results-empty {
display: grid;
place-items: center;
min-height: 100px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.dataflow-workspace {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.dataflow-action-guidance {
padding: 0 10px 8px;
}
.dataflow-workspace-toolbar {
min-height: 58px;
padding: 8px 10px;
min-width: 0;
}
.dataflow-identity-fields {
@@ -368,7 +209,6 @@
outline-offset: -2px;
}
.dataflow-palette,
.dataflow-inspector {
min-width: 0;
min-height: 0;
@@ -376,19 +216,10 @@
background: var(--panel-soft);
}
.dataflow-palette {
border-right: var(--border-line);
}
.dataflow-inspector {
border-left: var(--border-line);
}
.dataflow-panel-heading {
min-height: 44px;
padding: 8px 10px 8px 12px;
}
.dataflow-panel-heading > span {
min-width: 0;
}
@@ -413,127 +244,6 @@
white-space: nowrap;
}
.dataflow-palette-items {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 4px;
height: calc(100% - 44px);
padding: 8px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
}
.dataflow-palette-group {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 2px;
}
.dataflow-palette-group + .dataflow-palette-group {
margin-top: 6px;
padding-top: 8px;
border-top: var(--border-line);
}
.dataflow-palette-group h3 {
margin: 0;
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
padding: 3px 8px;
text-transform: uppercase;
}
.dataflow-palette-items button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 14px;
align-items: center;
gap: 7px;
min-height: 38px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: grab;
font: inherit;
font-size: 12px;
padding: 7px 8px;
text-align: left;
}
.dataflow-palette-items button:hover:not(:disabled),
.dataflow-palette-items button:focus-visible:not(:disabled) {
background: var(--primary-soft);
color: var(--text-strong);
outline: none;
}
.dataflow-palette-items button:active:not(:disabled) {
cursor: grabbing;
}
.dataflow-palette-items button:disabled {
cursor: default;
opacity: .42;
}
.dataflow-palette-add {
color: var(--accent);
}
.dataflow-editor-surface {
position: relative;
overflow: hidden;
background: var(--bg);
}
.dataflow-canvas {
position: absolute;
inset: 0;
}
.dataflow-canvas .react-flow {
background: var(--bg);
}
.dataflow-canvas .react-flow__background {
color: var(--line-dark);
}
.dataflow-canvas .react-flow__controls,
.dataflow-canvas .react-flow__minimap {
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-xs);
}
.dataflow-canvas .react-flow__controls-button {
border-bottom: var(--border-line);
background: var(--panel);
color: var(--text);
}
.dataflow-canvas .react-flow__minimap-mask {
fill: color-mix(in srgb, var(--bg) 76%, transparent);
}
.dataflow-canvas .react-flow__edge-path {
stroke: var(--line-dark);
stroke-width: 2;
}
.dataflow-canvas .react-flow__edge.selected .react-flow__edge-path,
.dataflow-canvas .react-flow__edge:hover .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 3;
}
.dataflow-canvas .react-flow__edge.dataflow-edge-proximity .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 3;
@@ -545,15 +255,6 @@
stroke-width: 3;
}
.dataflow-canvas-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
pointer-events: none;
}
.dataflow-node {
position: relative;
display: flex;
@@ -610,17 +311,6 @@
border-color: var(--danger-text);
}
.dataflow-node-icon {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
place-items: center;
border-radius: 5px;
background: var(--panel-soft);
color: var(--text-strong);
}
.dataflow-node-copy {
min-width: 0;
flex: 1 1 auto;
@@ -645,32 +335,6 @@
font-size: 10px;
}
.dataflow-node-count {
min-width: 22px;
border-radius: 999px;
background: var(--primary-soft);
color: var(--text-strong);
font-size: 10px;
font-variant-numeric: tabular-nums;
padding: 3px 5px;
text-align: center;
}
.dataflow-node-handle {
width: 13px;
height: 13px;
border: 3px solid var(--panel);
background: var(--line-dark);
}
.dataflow-node-handle:hover,
.dataflow-node-handle.connectingto,
.dataflow-node-handle.valid {
width: 17px;
height: 17px;
background: var(--accent);
}
.dataflow-node-port-label {
position: absolute;
left: 8px;
@@ -685,7 +349,7 @@
white-space: nowrap;
}
.dataflow-node-combine-join .dataflow-node-icon {
.dataflow-node-combine-join .definition-node-icon {
margin-left: 31px;
}
@@ -1077,8 +741,6 @@
}
.dataflow-sql-toolbar {
min-height: 44px;
padding: 7px 10px 7px 14px;
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
@@ -1115,8 +777,6 @@
}
.dataflow-results-toolbar {
min-height: 42px;
padding: 5px 9px;
flex-wrap: wrap;
}
@@ -1255,44 +915,7 @@
white-space: nowrap;
}
.dataflow-workspace-empty {
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
height: 100%;
color: var(--muted);
}
.dataflow-workspace-empty strong {
color: var(--text-strong);
}
.dataflow-workspace-empty .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dataflow-working-indicator {
position: absolute;
z-index: 20;
right: 12px;
bottom: 10px;
border: var(--border-line-dark);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-popover);
color: var(--text-strong);
font-size: 12px;
padding: 7px 10px;
}
@media (max-width: 1180px) {
.dataflow-shell {
grid-template-columns: 240px minmax(0, 1fr);
}
.dataflow-workspace-toolbar {
align-items: stretch;
flex-direction: column;
@@ -1312,10 +935,6 @@
}
@media (max-width: 900px) {
.dataflow-shell {
grid-template-columns: 210px minmax(0, 1fr);
}
.dataflow-editor {
grid-template-columns: 135px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(170px, 32%);
@@ -1348,16 +967,6 @@
height: calc(100dvh - 115px);
}
.dataflow-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(150px, 24%) minmax(0, 1fr);
}
.dataflow-pipeline-panel {
border-right: 0;
border-bottom: var(--border-line);
}
.dataflow-workspace-toolbar {
overflow-x: auto;
align-items: flex-start;
@@ -1383,41 +992,6 @@
grid-template-rows: auto minmax(0, 1fr) minmax(160px, 30%);
}
.dataflow-palette {
border-right: 0;
border-bottom: var(--border-line);
}
.dataflow-palette .dataflow-panel-heading {
display: none;
}
.dataflow-palette-items {
display: flex;
overflow-x: auto;
}
.dataflow-palette-group {
display: flex;
flex: 0 0 auto;
}
.dataflow-palette-group + .dataflow-palette-group {
margin-top: 0;
padding-top: 0;
padding-left: 6px;
border-top: 0;
border-left: var(--border-line);
}
.dataflow-palette-group h3 {
display: none;
}
.dataflow-palette-items button {
min-width: 128px;
}
.dataflow-inspector-fields {
grid-template-columns: 1fr 1fr;
}