Files
app/frontend/src/components/Flow/RunDialog.tsx
T
stroblme e85b18092f Flow inputs carry an origin hint, run or panel
The Inputs editor and the canvas label show it, and the run forms leave
panel inputs out.
2026-10-15 10:08:46 +02:00

191 lines
5.9 KiB
TypeScript

import { useEffect, useState } from "react"
import type { DType, FlowDef_Input, FlowInput_Input } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { parseByDtype } from "./FlowBoundary"
import { asText } from "./NodePanel"
/**
* A field per declared input, the way its type is entered.
*
* Held as text rather than as values: half-typed input is normal ("-", "1.",
* a JSON object with one brace), and a list of them is a sweep — both of
* which a parsed value would have thrown away. The caller parses on submit.
*/
export function ParamFields({
inputs,
values,
placeholderFor,
onChange,
}: {
inputs: FlowInput_Input[]
values: Record<string, string>
/** What an empty field says it takes, when the default is not enough. */
placeholderFor?: (dtype: DType | undefined) => string
onChange: (values: Record<string, string>) => void
}) {
return (
<div className="grid gap-3">
{inputs.map((declared) => {
const name = declared.spec?.name ?? ""
const dtype = declared.spec?.dtype
return (
<div key={name} className="grid gap-1.5">
<Label htmlFor={`param-${name}`} className="font-mono text-xs">
{name}
</Label>
{dtype === "bool" ? (
<Select
value={values[name] === "true" ? "true" : "false"}
onValueChange={(next) => onChange({ ...values, [name]: next })}
>
<SelectTrigger id={`param-${name}`} className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={`param-${name}`}
value={values[name] ?? ""}
placeholder={
placeholderFor?.(dtype) ??
(dtype === "artifact"
? "@run:<id>.<output> or sha256:…"
: `${dtype ?? "float"} or @run:<id>.<output>`)
}
className="text-sm"
onChange={(event) =>
onChange({ ...values, [name]: event.target.value })
}
/>
)}
</div>
)
})}
</div>
)
}
/** What the fields start from: the declared value of each, as text. */
export function initialText(inputs: FlowInput_Input[]): Record<string, string> {
return Object.fromEntries(
inputs.map((one) => [one.spec?.name ?? "", asText(one.initial)]),
)
}
/**
* The declared inputs worth drawing a field for.
*
* A panel input is a control's to write, so a run keeps its starting value
* and the form does not offer it as a parameter.
*/
export function declaredInputs(definition: FlowDef_Input): FlowInput_Input[] {
return (definition.inputs ?? []).filter(
(one) => one.spec?.name && one.origin !== "panel",
)
}
/**
* The parameters of one run, taken from the flow's inputs.
*
* A batch flow's inputs *are* its parameters — a run supplies values for them
* and keeps whatever it does not name — so this is a form over the same
* declarations the flow panel edits, prefilled with what they start from.
*/
export function RunDialog({
open,
definition,
pending,
onOpenChange,
onRun,
}: {
open: boolean
definition: FlowDef_Input
pending: boolean
onOpenChange: (open: boolean) => void
onRun: (params: Record<string, unknown>) => void
}) {
const inputs = declaredInputs(definition)
const [values, setValues] = useState<Record<string, string>>({})
// Opening is what fills the form: an edit to the flow between two runs
// should show up, and the last run's values should not linger. The inputs
// are read then rather than watched, so typing over one is not undone.
// biome-ignore lint/correctness/useExhaustiveDependencies: opening is the dependency.
useEffect(() => {
if (!open) return
setValues(initialText(inputs))
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="run-dialog">
<DialogHeader>
<DialogTitle>Run {definition.title || definition.name}</DialogTitle>
<DialogDescription>
{inputs.length === 0
? "This flow declares no parameters, so there is nothing to choose."
: "The run starts from these. Everything it computes is dropped afterwards except its result."}
</DialogDescription>
</DialogHeader>
<ParamFields inputs={inputs} values={values} onChange={setValues} />
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="brand"
disabled={pending}
data-testid="submit-run"
onClick={() => onRun(parseParams(inputs, values))}
>
Run
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/**
* The fields as the engine takes them.
*
* A field nobody filled in is left out, which is what keeps its declared
* value — an empty parameter is not the same as a zero.
*/
export function parseParams(
inputs: FlowInput_Input[],
values: Record<string, string>,
): Record<string, unknown> {
const params: Record<string, unknown> = {}
for (const declared of inputs) {
const name = declared.spec?.name ?? ""
const parsed = parseByDtype(declared.spec?.dtype, values[name] ?? "")
if (parsed !== null) params[name] = parsed
}
return params
}