Fix union SQL and selection previews
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user