import { useMemo, useRef, useState } from "react"; import { stableStringify, stringifyCsv, triggerBlobDownload, type JsonValue, } from "@add-ideas/toolbox-helpers"; import { parseData, type DataFormat } from "../core/data"; import { runFlowJob } from "../core/flow-client"; import { exportRecipe, importRecipe, records, renderPipelineSvg, runPipeline, type PipelineResult, type Stage, type StageType, } from "../core/pipeline"; const SAMPLE = '[{"name":"Ada","team":"blue","score":91},{"name":"Grace","team":"blue","score":84},{"name":"Linus","team":"green","score":73}]', RIGHT = '[{"team":"blue","lead":"Mina"},{"team":"green","lead":"Sam"}]'; const DEFAULT: Stage[] = [ { id: "stage-1", type: "filter", enabled: true, config: { field: "score", operator: ">=", value: "80" }, }, { id: "stage-2", type: "sort", enabled: true, config: { field: "score", direction: "desc" }, }, ]; export function Workbench() { const [source, setSource] = useState(SAMPLE), [format, setFormat] = useState("json"), [input, setInput] = useState(() => records(parseData(SAMPLE, "json").rows)), [rightSource, setRightSource] = useState(RIGHT), [right, setRight] = useState(() => records(parseData(RIGHT, "json").rows)), [stages, setStages] = useState(DEFAULT), [result, setResult] = useState(() => runPipeline( records(parseData(SAMPLE, "json").rows), DEFAULT, records(parseData(RIGHT, "json").rows), ), ), [selected, setSelected] = useState(2), [status, setStatus] = useState("Sample pipeline completed locally."), [recipeText, setRecipeText] = useState(""), [busy, setBusy] = useState(false), [progress, setProgress] = useState(0); const operation = useRef<{ revision: number; controller?: AbortController }>({ revision: 0, }); const snapshot = result.snapshots[Math.min(selected, result.snapshots.length - 1)]!; function parsePrimary() { try { const next = records(parseData(source, format).rows); setInput(next); setStatus( `Parsed ${next.length} primary rows. Run the recipe to replace results.`, ); } catch (e) { setStatus(`${msg(e)} Last valid input retained.`); } } function parseRight() { try { const next = records(parseData(rightSource, "json").rows); setRight(next); setStatus(`Parsed ${next.length} secondary rows.`); } catch (e) { setStatus(`${msg(e)} Last valid secondary input retained.`); } } async function run() { operation.current.controller?.abort(); const controller = new AbortController(), revision = ++operation.current.revision; operation.current.controller = controller; setBusy(true); setProgress(0); try { const next = await runFlowJob(input, stages, right, { signal: controller.signal, onProgress: (item) => { if (operation.current.revision !== revision) return; setProgress(item.total ? item.completed / item.total : 0); setStatus(`Running ${item.stage} locally…`); }, }); if (operation.current.revision !== revision) return; setResult(next); setSelected(next.snapshots.length - 1); setStatus( `Completed ${stages.filter((s) => s.enabled).length} stages; ${next.rows.length} rows remain.`, ); } catch (e) { if (operation.current.revision !== revision) return; if (e instanceof DOMException && e.name === "AbortError") { setStatus( "Pipeline run cancelled. Last successful snapshots remain visible.", ); return; } setStatus(`${msg(e)} Last successful stage snapshots remain visible.`); } finally { if (operation.current.revision === revision) { setBusy(false); operation.current.controller = undefined; } } } function update(index: number, stage: Stage) { setStages((s) => s.map((x, i) => (i === index ? stage : x))); } function add(type: StageType) { setStages((s) => [ ...s, { id: nextStageId(s), type, enabled: true, config: defaults(type), }, ]); } function download(kind: "json" | "csv" | "recipe" | "svg") { if (kind === "recipe") triggerBlobDownload( new Blob([exportRecipe(stages)], { type: "application/json" }), "flow-recipe.json", ); else if (kind === "svg") triggerBlobDownload( new Blob([renderPipelineSvg(stages)], { type: "image/svg+xml" }), "flow-pipeline.svg", ); else if (kind === "json") triggerBlobDownload( new Blob([stableStringify(result.rows, 2)], { type: "application/json", }), "flow-result.json", ); else { const fields = [...new Set(result.rows.flatMap(Object.keys))], table = [ fields, ...result.rows.map((row) => fields.map((f) => scalar(row[f] ?? null)), ), ]; triggerBlobDownload( new Blob([stringifyCsv(table)], { type: "text/csv" }), "flow-result.csv", ); } } async function openData(file: File | undefined, secondary = false) { if (!file) return; try { if (file.size > 2 * 1024 * 1024) throw new Error("Input file exceeds the 2 MiB limit."); const text = await file.text(), detected = inferFormat(file.name), next = records(parseData(text, detected).rows); if (secondary) { setRightSource(text); setRight(next); setStatus(`Opened ${next.length} secondary rows from ${file.name}.`); } else { setFormat(detected); setSource(text); setInput(next); setStatus(`Opened ${next.length} primary rows from ${file.name}.`); } } catch (e) { setStatus(`${msg(e)} Last valid input retained.`); } } async function openRecipe(file: File | undefined) { if (!file) return; try { if (file.size > 256 * 1024) throw new Error("Recipe exceeds 256 KiB."); const text = await file.text(), next = importRecipe(text); setRecipeText(text); setStages(next); setStatus(`Imported ${next.length} validated stages from ${file.name}.`); } catch (e) { setStatus(msg(e)); } } function loadRecipe() { try { const next = importRecipe(recipeText); setStages(next); setStatus(`Imported ${next.length} validated stages. Run to apply.`); } catch (e) { setStatus(msg(e)); } } return (

Inspectable local transformations

Build data flows without code.

Compose fixed primitives, inspect every transition and carry explicit losses with a deterministic recipe.

{busy && ( )}

{status}

{busy && ( )}

Primary source