/** * The adapter mapping form for a NAMED update. It renders one row per declared * input key of the update jq; each row picks ONE of three value sources — a picked * field (from the run's output / input schema, when a schema is known), a hardcoded * literal, or a jq expression — and the whole form compiles into ONE adapter jq over * `{ output, input }` that constructs the update jq's declared input object. Each * row shows its own jq on demand, and the whole compiled adapter is shown on demand. * * A raw-jq escape hatch (`Write jq`) authors the adapter directly when the mapping needs * more than the row form expresses. An update that declares NO input needs no adapter, so * it shows a tool-output passthrough and stores `null` (the escape hatch stays available). * * The stored value always equals what the form shows (WYSIWYG): an accepted default is * emitted on mount, so it is never left as the empty string the platform's save refuses. * The emitted adapter is a non-empty jq or `null`, never `''`. */ import { type ReactNode, useEffect, useState } from 'react'; import { Button } from '../components/primitives'; import { compileAdapter, type MappingRow, parseAdapter } from './adapter'; import { MappingRowEditor } from './adapter-mapping-row'; import { BindingJqField, type TemplateJqSuggestion } from './BindingJqField'; import type { BindingSourceSchemas } from './types'; export interface AdapterMappingProps { /** The declared input keys of the update jq — one row each. Empty ⇒ raw jq only. */ readonly declaredInput: readonly string[]; /** The current adapter jq (compiled or authored) stored on the update; `''` ⇒ none stored. */ readonly value: string; /** Emits the adapter to store: a non-empty jq, or `null` for no adapter (never `''`). */ readonly onChange: (adapterJq: string | null) => void; /** The output / input field paths a picker offers. */ readonly sources?: BindingSourceSchemas; /** Template jq offered as `tjq_({…})` inserts in the jq escape hatch. */ readonly suggestions?: readonly TemplateJqSuggestion[]; } /** One row per declared input key, each defaulting to the whole run output. */ function defaultRows(declaredInput: readonly string[]): MappingRow[] { return declaredInput.map((target): MappingRow => ({ target, source: { kind: 'field', root: 'output', path: [] }, })); } export function AdapterMapping({ declaredInput, value, onChange, sources, suggestions, }: AdapterMappingProps): ReactNode { const canMap = declaredInput.length > 0; // Rebuild the form from STORED data: a stored adapter that parses back to rows reopens // as the mapping form; anything else opens in the raw-jq escape hatch. With no declared // input the update needs no adapter, so it opens on the tool-output passthrough (a // stored jq still reopens the escape hatch). const recovered = value.trim() === '' ? null : parseAdapter(value); const [mode, setMode] = useState<'fields' | 'jq'>(() => { if (value.trim() === '') return 'fields'; return canMap && recovered !== null ? 'fields' : 'jq'; }); const [rows, setRows] = useState(() => recovered ?? defaultRows(declaredInput)); // Set when a toggle to fields mode finds a jq the row form cannot represent: the // note explains why the view stayed on the raw jq (see `toFields`). const [unmappable, setUnmappable] = useState(false); const emit = (nextRows: readonly MappingRow[]): void => { // WYSIWYG: store exactly what the form shows. A compile failure has no valid adapter, // so emit `null` (no adapter) — never the empty string the platform's save refuses. const compiled = compileAdapter(nextRows); onChange(compiled.ok ? compiled.jq : null); }; // WYSIWYG on mount: with no stored value the form shows a default — the compiled // declared-input rows when mappable, else the tool-output passthrough — so store it AT // ONCE. An operator who accepts the shown default must not leave an empty adapter the // platform's save-time compile check refuses. Re-runs when the declared input changes // the shown default; the guard preserves a stored/authored value untouched. const declaredKey = declaredInput.join(''); useEffect(() => { if (mode !== 'fields' || value.trim() !== '') return; const seeded = canMap ? compileAdapter(defaultRows(declaredInput)) : null; onChange(seeded?.ok ? seeded.jq : null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [declaredKey]); // Toggle to the row form by re-deriving rows from the CURRENT stored jq, never from // the rows held since mount (those go stale while the user edits the raw jq, so // compiling them would silently overwrite the authored jq). A non-empty jq the row // form cannot represent keeps the raw view and surfaces a note instead of clobbering // it; a clean round-trip re-emits nothing, so an authored jq is preserved byte-for-byte. // With no declared input there is nothing to map — the toggle returns to the passthrough. const toFields = (): void => { if (!canMap) { setMode('fields'); setUnmappable(false); onChange(null); return; } const parsed = value.trim() === '' ? null : parseAdapter(value); if (value.trim() !== '' && parsed === null) { setUnmappable(true); return; } const nextRows = parsed ?? defaultRows(declaredInput); setRows(nextRows); setMode('fields'); setUnmappable(false); const compiled = compileAdapter(nextRows); if (compiled.ok && compiled.jq !== value) onChange(compiled.jq); }; // The raw-jq edit path clears a stale unmappable note so a re-mapping attempt sees the // edited jq, then forwards the edit — normalizing an empty/whitespace box to `null` (no // adapter) so the empty string is never stored. const onJqChange = (next: string): void => { if (unmappable) setUnmappable(false); onChange(next.trim() === '' ? null : next); }; const setRow = (index: number, next: MappingRow): void => { const nextRows = rows.map((row, position) => (position === index ? next : row)); setRows(nextRows); emit(nextRows); }; const compiled = compileAdapter(rows); const [showCompiled, setShowCompiled] = useState(false); // An adapter is refused empty ONLY when the update declares inputs to fill; a // no-declared-input update legitimately stores no adapter (the passthrough). const rawError = canMap && mode === 'jq' && value.trim() === '' ? 'Map the declared inputs or write an adapter jq — an empty adapter is refused.' : undefined; const onWriteJq = (): void => { // Seed the escape hatch with the compiled adapter so nothing is lost — only when // there are rows to compile; a no-declared-input update opens empty. if (canMap && value.trim() === '' && compiled.ok) onChange(compiled.jq); setMode('jq'); }; return (
{unmappable ? (

This adapter jq cannot be shown as fields. Edit it here.

) : null} {mode === 'fields' ? ( { setShowCompiled((open) => !open); }} onRowChange={setRow} /> ) : ( )}
); } /** The Map-fields / Write-jq mode switch. */ function AdapterModeToolbar({ canMap, mode, onMapFields, onWriteJq, }: { readonly canMap: boolean; readonly mode: 'fields' | 'jq'; readonly onMapFields: () => void; readonly onWriteJq: () => void; }): ReactNode { return (
); } /** * The fields-mode body: one editor per declared-input row, the compile error, and the * on-demand compiled-adapter viewer. A no-declared-input update maps nothing and shows * the tool-output passthrough note instead. */ function MappingRowsBody({ canMap, rows, sources, suggestions, compiled, showCompiled, onToggleCompiled, onRowChange, }: { readonly canMap: boolean; readonly rows: readonly MappingRow[]; readonly sources?: BindingSourceSchemas; readonly suggestions?: readonly TemplateJqSuggestion[]; readonly compiled: ReturnType; readonly showCompiled: boolean; readonly onToggleCompiled: () => void; readonly onRowChange: (index: number, row: MappingRow) => void; }): ReactNode { if (!canMap) { return (

This update runs with the tool output as its input.

); } return ( <> {rows.map((row, index) => ( { onRowChange(index, next); }} /> ))} {!compiled.ok ? (

{compiled.error}

) : null}
{showCompiled ? (
            {compiled.ok ? compiled.jq : compiled.error}
          
) : null}
); }