Fix union SQL and selection previews

This commit is contained in:
2026-07-28 17:54:28 +02:00
parent 521829a7fc
commit 74f1210a32
9 changed files with 498 additions and 141 deletions

View File

@@ -11,6 +11,22 @@ const checks = [
[page.includes("Apply SQL"), "SQL workbench"],
[page.includes("<NodeInspector"), "node inspector"],
[page.includes("previewDataflowPipeline"), "preview action"],
[
page.includes('inactiveLabel="Manual"')
&& page.includes('activeLabel="Auto"')
&& page.includes("void runPreview(selectedNodeId, true)"),
"selection-driven automatic and manual preview modes"
],
[
!page.includes('aria-label="Preview stage"')
&& page.includes('className="dataflow-preview-stage"'),
"selected-node preview stage without dropdown"
],
[
page.includes('resetKey={error} floating')
&& !css.includes(".dataflow-alerts"),
"central floating dismissible alerts"
],
[page.includes("useUnsavedDraftGuard"), "unsaved-change guard"],
[canvas.includes("application/x-govoplan-dataflow-node"), "palette drop handling"],
[canvas.includes("isValidConnection"), "edge validation"],
@@ -20,6 +36,11 @@ const checks = [
[canvas.includes("onReconnect={onReconnect}"), "edge reconnection"],
[canvas.includes("onReconnectEnd="), "edge deletion on dropped reconnection"],
[canvas.includes("selectedEdgeId"), "persistent edge selection"],
[
canvas.includes("newGraphEdgeId()")
&& interactions.includes("return `edge-${crypto.randomUUID()}`"),
"bounded opaque edge identifiers"
],
[
canvas.includes("closestProximityConnection")
&& interactions.includes("definitionConnectionError"),

View File

@@ -26,6 +26,7 @@ import DataflowNode, { type DataflowFlowNode } from "./DataflowNode";
import {
closestProximityConnection,
graphEdgesFromFlow,
newGraphEdgeId,
proximityPreviewEdge
} from "./graphInteractions";
import { FALLBACK_NODE_LIBRARY, newNode } from "./model";
@@ -164,7 +165,7 @@ export default function DataflowCanvas({
const next = addEdge(
{
...connection,
id: `edge-${connection.source}-${connection.target}-${crypto.randomUUID()}`,
id: newGraphEdgeId(),
type: "smoothstep"
},
edges
@@ -280,7 +281,7 @@ export default function DataflowCanvas({
const nextEdges = addEdge(
{
...connection,
id: `edge-${crypto.randomUUID()}`,
id: newGraphEdgeId(),
type: "smoothstep",
className: "dataflow-edge"
},

View File

@@ -2,6 +2,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent
} from "react";
@@ -92,6 +93,7 @@ type ResultTab = "preview" | "diagnostics";
type SnapshotFormat = "json" | "csv";
const NODE_CATEGORY_ORDER: NodeCategory[] = ["load", "combine", "filter", "transform", "output"];
const AUTO_PREVIEW_STORAGE_KEY = "govoplan.dataflow.auto-preview";
export default function DataflowPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const { requestNavigation, requestDiscard } = useUnsavedChanges();
@@ -108,6 +110,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
const [diagnostics, setDiagnostics] = useState<DataflowDiagnostic[]>([]);
const [nodeDiagnostics, setNodeDiagnostics] = useState<NodePreviewDiagnostic[]>([]);
const [preview, setPreview] = useState<PipelinePreview | null>(null);
const [autoPreview, setAutoPreview] = useState(readAutoPreviewPreference);
const [resultOpen, setResultOpen] = useState(false);
const [resultTab, setResultTab] = useState<ResultTab>("preview");
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -120,6 +123,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
const [sources, setSources] = useState<TabularSource[]>([]);
const [sourceCatalogueAvailable, setSourceCatalogueAvailable] = useState(false);
const [sourceCatalogueWritable, setSourceCatalogueWritable] = useState(false);
const previewRequestId = useRef(0);
const dirty = Boolean(draft) && draftFingerprint(draft) !== draftFingerprint(savedDraft);
const canWrite = hasScope(auth, "dataflow:pipeline:write") || hasScope(auth, "dataflow:pipeline:admin");
@@ -345,10 +349,12 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
};
const updateGraph = (graph: PipelineDraft["graph"]) => {
previewRequestId.current += 1;
updateDraft({ graph });
setDiagnostics([]);
setNodeDiagnostics([]);
setPreview(null);
setWorking(false);
};
const validate = async () => {
@@ -426,13 +432,18 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
await applySql(true);
};
const runPreview = async (previewNodeId?: string) => {
const runPreview = useCallback(async (
previewNodeId?: string,
quiet = false
) => {
if (!draft || !canPreview) return;
const targetNodeId = previewNodeId
?? draft.graph.nodes.find((node) => node.type === "output")?.id;
const requestId = previewRequestId.current + 1;
previewRequestId.current = requestId;
setWorking(true);
setError("");
setSuccess("");
if (!quiet) setSuccess("");
try {
const response = await previewDataflowPipeline(settings, !dirty && draft.id && draft.currentRevision
? {
@@ -448,6 +459,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
preview_node_id: targetNodeId,
row_limit: 100
});
if (requestId !== previewRequestId.current) return;
setPreview(response);
setDiagnostics(response.diagnostics);
setNodeDiagnostics(response.node_diagnostics);
@@ -457,7 +469,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
? "preview"
: "diagnostics"
);
if (response.status === "succeeded" && response.node_preview) {
if (!quiet && response.status === "succeeded" && response.node_preview) {
const node = draft.graph.nodes.find(
(candidate) => candidate.id === response.node_preview?.node_id
);
@@ -467,10 +479,38 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
);
}
} catch (previewError) {
if (requestId !== previewRequestId.current) return;
setError(apiErrorMessage(previewError));
} finally {
setWorking(false);
if (requestId === previewRequestId.current) setWorking(false);
}
}, [canPreview, dirty, draft, settings]);
useEffect(() => {
if (
!autoPreview
|| !selectedNodeId
|| !canPreview
|| draft?.editorMode !== "graph"
) {
return;
}
const timeout = window.setTimeout(() => {
void runPreview(selectedNodeId, true);
}, 350);
return () => window.clearTimeout(timeout);
}, [autoPreview, canPreview, draft?.editorMode, runPreview, selectedNodeId]);
const selectNode = useCallback((nodeId: string | null) => {
setSelectedNodeId(nodeId);
if (nodeId && nodeId !== preview?.node_preview?.node_id) {
setPreview(null);
}
}, [preview?.node_preview?.node_id]);
const changeAutoPreview = (enabled: boolean) => {
setAutoPreview(enabled);
writeAutoPreviewPreference(enabled);
};
const removePipeline = async () => {
@@ -614,14 +654,23 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
</Button>
<Button
variant="primary"
onClick={() => void runPreview()}
onClick={() => void runPreview(selectedNodeId ?? undefined)}
disabled={working || !canPreview}
disabledReason={
draft.governance?.actions.run?.reason ?? undefined
}
>
<Play size={16} /> Preview
{preview ? <RefreshCw size={16} /> : <Play size={16} />}
{preview ? "Refresh" : "Preview"}
</Button>
<ToggleSwitch
label="Preview updates"
inactiveLabel="Manual"
activeLabel="Auto"
checked={autoPreview}
onChange={changeAutoPreview}
disabled={!canPreview}
/>
<Button
onClick={() => setRunOpen(true)}
disabled={working || !canRun || dirty || !canStartSavedRun || !draft.currentRevision}
@@ -683,11 +732,11 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
</Button>
</div>
</div>
{(error || success) ? (
<div className="dataflow-alerts">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
</div>
{error ? (
<DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>
) : null}
{success ? (
<DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>
) : null}
<div className={`dataflow-editor ${draft.editorMode === "sql" ? "is-sql" : ""}`}>
{draft.editorMode === "graph" ? (
@@ -742,7 +791,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
selectedNodeId={selectedNodeId}
readOnly={!canEdit}
onGraphChange={updateGraph}
onSelectNode={setSelectedNodeId}
onSelectNode={selectNode}
/>
</ReactFlowProvider>
) : (
@@ -793,10 +842,9 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
onTabChange={setResultTab}
preview={preview}
nodes={draft.graph.nodes}
working={working}
diagnostics={diagnostics}
nodeDiagnostics={nodeDiagnostics}
onPreviewNodeChange={(nodeId) => void runPreview(nodeId)}
selectedNodeId={selectedNodeId}
onClose={() => setResultOpen(false)}
/>
) : null}
@@ -1872,24 +1920,23 @@ function ResultPanel({
onTabChange,
preview,
nodes,
working,
diagnostics,
nodeDiagnostics,
onPreviewNodeChange,
selectedNodeId,
onClose
}: {
tab: ResultTab;
onTabChange: (tab: ResultTab) => void;
preview: PipelinePreview | null;
nodes: PipelineGraphNode[];
working: boolean;
diagnostics: DataflowDiagnostic[];
nodeDiagnostics: NodePreviewDiagnostic[];
onPreviewNodeChange: (nodeId: string) => void;
selectedNodeId: string | null;
onClose: () => void;
}) {
const outputNodeId = nodes.find((node) => node.type === "output")?.id ?? "";
const previewNodeId = preview?.node_preview?.node_id ?? outputNodeId;
const previewNodeId = preview?.node_preview?.node_id ?? selectedNodeId ?? outputNodeId;
const previewNode = nodes.find((node) => node.id === previewNodeId);
const previewRows = preview?.node_preview?.total_rows ?? preview?.total_rows;
return (
<section className="dataflow-results" aria-label="Pipeline results">
@@ -1904,19 +1951,11 @@ function ResultPanel({
value={tab}
onChange={onTabChange}
/>
{tab === "preview" && nodes.length ? (
<select
aria-label="Preview stage"
value={previewNodeId}
onChange={(event) => onPreviewNodeChange(event.target.value)}
disabled={working}
>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.label}{node.type === "output" ? " (final output)" : ""}
</option>
))}
</select>
{tab === "preview" && previewNode ? (
<span className="dataflow-preview-stage">
{previewNode.label}
{previewNode.type === "output" ? " (final output)" : ""}
</span>
) : null}
</div>
<Button variant="ghost" onClick={onClose}>Close</Button>
@@ -2016,6 +2055,26 @@ function uniqueNodeExists(draft: PipelineDraft, type: string): boolean {
return false;
}
function readAutoPreviewPreference(): boolean {
if (typeof window === "undefined") return true;
try {
return window.localStorage.getItem(AUTO_PREVIEW_STORAGE_KEY) !== "manual";
} catch {
return true;
}
}
function writeAutoPreviewPreference(enabled: boolean): void {
try {
window.localStorage.setItem(
AUTO_PREVIEW_STORAGE_KEY,
enabled ? "auto" : "manual"
);
} catch {
// The preference is non-essential when browser storage is unavailable.
}
}
function startPaletteDrag(event: DragEvent<HTMLButtonElement>, type: string) {
event.dataTransfer.setData("application/x-govoplan-dataflow-node", type);
event.dataTransfer.effectAllowed = "copy";

View File

@@ -9,6 +9,10 @@ import type { DataflowFlowNode } from "./DataflowNode";
const PROXIMITY_GAP = 72;
const PROXIMITY_EDGE_ID = "dataflow-proximity-preview";
export function newGraphEdgeId(): string {
return `edge-${crypto.randomUUID()}`;
}
export function graphEdgesFromFlow(edges: Edge[]): PipelineGraph["edges"] {
return edges
.filter((edge) => edge.id !== PROXIMITY_EDGE_ID)

View File

@@ -343,20 +343,9 @@
gap: 5px;
}
.dataflow-alerts {
position: absolute;
z-index: 12;
top: 66px;
right: 12px;
display: grid;
width: min(500px, calc(100% - 24px));
gap: 6px;
pointer-events: none;
}
.dataflow-alerts .alert {
pointer-events: auto;
box-shadow: var(--shadow-popover);
.dataflow-command-bar .toggle-switch-row {
min-height: 34px;
white-space: nowrap;
}
.dataflow-editor {
@@ -923,9 +912,14 @@
flex-wrap: wrap;
}
.dataflow-results-view-controls select {
width: clamp(150px, 22vw, 280px);
min-height: 30px;
.dataflow-preview-stage {
max-width: min(36vw, 320px);
overflow: hidden;
color: var(--text-strong);
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.dataflow-preview-table-wrap,