conductor/frontend/src/components/Preview/usePreviewRuntime.ts

840 lines
35 KiB
TypeScript

/**
* usePreviewRuntime
*
* Owns all runtime state for Preview Mode:
* - per-component dynamic values (e.g. the JSON Viewer's displayed content)
* - per-action runtime state (response, loading, error)
* - per-button loading / error state
* - per-variable runtime values (Step 18.1)
* - the click handler that resolves bindings, calls the proxy, and writes
* the result to target components and variables via response-mapping bindings
* - TextInput value tracking so template interpolation can read them
*
* This hook is intentionally decoupled from the project store so Preview Mode
* has its own ephemeral state that does not mutate the design-time document.
*
* ── Binding model (schema-conformant) ────────────────────────────────────────
*
* The schema uses a two-part model for button-click → REST → viewer wiring:
*
* 1. Component events array (component.events[]):
* { "event": "onClick", "actionId": "<actionId>" }
* Declares that clicking this component fires the named action.
*
* 2. Project-level bindings (project.bindings[]):
* { "id": "...", "source": "actions.<actionId>.response.body",
* "target": "components.<viewerName>.value", "trigger": "onSuccess" }
* Routes the action response (or a field of it) to a target component.
*
* This is the same shape used in valid-response-mapping-basic.json.
* Legacy Step 15 examples using trigger "onClick" remain compatible.
*
* ── Step 16: Input-to-Request template interpolation ─────────────────────────
*
* REST action fields (url, queryParameters values, headers values,
* bodyTemplate) may contain {{components.<name>.value}} placeholders.
* Before executing, the runtime resolves these against the current TextInput
* values stored in componentState. The canonical project JSON is never
* mutated — only an execution copy of the action is modified.
*
* Template utilities live in templateUtils.ts and are shared with
* ActionInspector for Step 16.5 diagnostics.
*
* ── Step 17.1: Response mapping ───────────────────────────────────────────────
*
* After a successful proxy call, the runtime:
* 1. Stores the full ProxyResponse in actionState[actionId].response
* 2. Iterates project.bindings to find those whose source identifies the
* completed action (e.g. "actions.<id>.response.body.origin")
* 3. Resolves each source path against the stored response
* 4. Writes resolved values to componentState[targetId].value
*
* Supported source paths: See docs/response-mapping-model.md §4
* Supported target paths: components.<name>.value (display and input components)
* variables.<name> (Step 18.1)
* Supported triggers: onSuccess (canonical), onClick (legacy compat)
*
* Canonical project JSON is never mutated during execution.
*
* ── Step 18.1: Runtime variables ─────────────────────────────────────────────
*
* Variables are initialized from project.variables.defaultValue on every
* Preview mount. Runtime changes (from response bindings) do not mutate the
* canonical project document. The variable runtime state is local to this
* hook instance; leaving and re-entering Preview resets to configured defaults.
*
* Action request templates may reference {{variables.<name>}} placeholders.
* Undeclared variable names in templates are configuration errors — the action
* is not executed and Preview surfaces a useful error message.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Binding, CanvasComponent, ProjectDocument, RestAction } from '../../types/project';
import type { ProxyResponse } from '../../api/proxyApi';
import { executeAction } from '../../api/proxyApi';
import { renderAction } from './templateUtils';
import {
type ActionRuntimeState,
type ActionRuntimeStateMap,
isTargetPropertySupported,
classifyTrigger,
parseActionSourcePath,
parseComponentTargetPath,
parseVariableTargetPath,
resolveActionSource,
normalizeDropdownOptions,
reconcileDropdownSelection,
normalizeTableRows,
} from './bindingUtils';
import { type VariableRuntimeState, initializeVariableState } from './variableUtils';
// ── Public types ──────────────────────────────────────────────────────────────
/**
* Ephemeral runtime state for a single component.
* - JsonViewer uses `value` to display action responses.
* - Label uses `value` to override its configured label text.
* - TextInput uses `textValue` to track the current typed value.
* - Dropdown uses `options` to override configured static options,
* and `value` to track the current selection.
*/
export type ComponentRuntimeState = {
/** Current display value (JSON-serialisable). Shown instead of defaultValue / label. */
value?: unknown;
/** Current string value of a TextInput component. */
textValue?: string;
/** Runtime options override for a Dropdown (replaces configured options when defined). */
options?: import('../../types/project').DropdownOption[];
/**
* Runtime rows for a Table component.
* When defined (including an empty array), overrides configured properties.rows.
* Cleared when rows are replaced by a response binding (Step 17.5).
* Never persisted to canonical project JSON.
*/
rows?: import('../../types/project').TableRow[];
/** True while a bound action is in-flight for this component. */
loading?: boolean;
/** Error message to display when the last action for this component failed. */
error?: string;
/** Selected row index for Table components (runtime only, never persisted). */
selectedIndex?: number;
/** Selected row data for Table components (runtime only, never persisted). */
selectedRow?: Record<string, unknown>;
};
export type PreviewRuntime = {
/**
* Map from component ID → ephemeral runtime state.
* Consumers read this to override design-time defaultValue / label.
*/
componentState: Record<string, ComponentRuntimeState>;
/**
* Loading state keyed by Button component ID.
* True while the button's bound action is in-flight.
*/
buttonLoading: Record<string, boolean>;
/**
* Ephemeral action runtime state keyed by action ID.
* Stores the latest ProxyResponse for each executed action.
*/
actionState: ActionRuntimeStateMap;
/**
* Ephemeral runtime variable state keyed by variable name. (Step 18.1)
* Initialized from project.variables.defaultValue on Preview mount.
* Updated by onSuccess response bindings targeting variables.<name>.
* Never persisted to the canonical project document or SQLite.
*/
variableState: VariableRuntimeState;
/**
* Call this when a Button is clicked in Preview Mode.
* Reads the component's events[] to find which action to execute, then reads
* project.bindings[] to route the response to target components and variables.
*/
handleButtonClick: (buttonId: string) => void;
/**
* Call this when a TextInput value changes in Preview Mode.
* Stores the value in componentState so template interpolation can read it.
*/
handleTextInputChange: (componentId: string, value: string) => void;
/**
* Stores a generic component value for Checkbox and RadioGroup inputs.
* Runtime state remains separate from canonical project JSON.
*/
handleValueChange: (componentId: string, value: unknown) => void;
/**
* Call this when a Dropdown selection changes in Preview Mode.
* Stores the selected option value in componentState.value so
* {{components.<name>.value}} template interpolation can read it.
* Also fires any onChange component events declared on the Dropdown.
*/
handleDropdownChange: (componentId: string, value: string) => void;
/**
* Call this when a Table row is clicked in Preview Mode.
* Stores selectedIndex and selectedRow in componentState.
* Disabled Tables do not change selection.
*/
handleTableRowSelect: (componentId: string, index: number, row: Record<string, unknown>) => void;
};
// ── Re-export for consumers that only import from this module ─────────────────
export type { ActionRuntimeState, ActionRuntimeStateMap };
// ── Internal: apply response bindings ────────────────────────────────────────
/**
* After a successful proxy call, iterates project.bindings and applies any
* response-mapping bindings for the completed action.
*
* Returns a partial componentState update (may be empty if no bindings match).
* Console-warns on mapping failures rather than throwing.
*
* This function is pure (no side effects other than console warnings) — the
* caller applies the returned updates to React state.
*
* currentComponentState is used for selection reconciliation on .options targets.
*/
type ApplyResponseBindingsResult = {
componentUpdates: Record<string, ComponentRuntimeState>;
variableUpdates: VariableRuntimeState;
};
function applyResponseBindings(
actionId: string,
proxyResponse: ProxyResponse,
bindings: Binding[],
allComponents: CanvasComponent[],
updatedActionState: ActionRuntimeStateMap,
currentComponentState: Record<string, ComponentRuntimeState>,
declaredVariableNames: Set<string>,
): ApplyResponseBindingsResult {
// Build name → component map (used for target resolution)
const byName = new Map<string, CanvasComponent[]>();
for (const c of allComponents) {
const list = byName.get(c.name) ?? [];
list.push(c);
byName.set(c.name, list);
}
const componentUpdates: Record<string, ComponentRuntimeState> = {};
const variableUpdates: VariableRuntimeState = {};
for (const binding of bindings) {
// ── 1. Parse and match source ──────────────────────────────────────────
const parsedSource = parseActionSourcePath(binding.source);
// Not an action-response source — skip
if (!parsedSource) continue;
// Not for this action — skip (exact ID match, no substring)
if (parsedSource.actionId !== actionId) continue;
// ── 2. Parse target — must happen before trigger diagnostics so that
// variable targets and component targets can be handled separately.
const parsedVariableTarget = parseVariableTargetPath(binding.target);
const parsedTarget = parseComponentTargetPath(binding.target);
// ── 3. Trigger check — branched by target type ────────────────────────
const triggerClass = classifyTrigger(binding.trigger);
if (parsedVariableTarget) {
// Variable targets require onSuccess exactly — onClick (legacy) is not
// accepted here, and unsupported triggers also get a variable-specific
// message instead of the generic component-binding message.
if (triggerClass !== 'onSuccess') {
console.warn(
`[Preview] Binding "${binding.id}": variable response bindings require trigger ` +
`"onSuccess". Got "${binding.trigger ?? 'onChange'}". ` +
`Binding skipped. Change the trigger to "onSuccess".`,
);
continue;
}
} else {
// Component target (or malformed target) — existing trigger behavior.
if (triggerClass === 'unsupported') {
console.warn(
`[Preview] Binding "${binding.id}": trigger "${binding.trigger ?? 'onChange'}" is not ` +
`supported for action-response bindings. Supported: onSuccess, onClick (legacy). ` +
`Binding skipped.`,
);
continue;
}
// Legacy onClick: compatible but log an info nudge (once per binding)
if (triggerClass === 'onClick-legacy') {
console.info(
`[Preview] Binding "${binding.id}": trigger "onClick" is a legacy response-mapping ` +
`trigger. Consider migrating to "onSuccess".`,
);
}
}
// onSuccess only fires when ok === true (legacy onClick also respects this
// since we only call applyResponseBindings on success paths)
if (!proxyResponse.ok) continue;
// ── 4. Resolve source value ────────────────────────────────────────────
const sourceResult = resolveActionSource(parsedSource, updatedActionState);
if (!sourceResult.found) {
console.warn(
`[Preview] Binding "${binding.id}" source "${binding.source}": ${sourceResult.reason}`,
);
continue;
}
// ── 5a. Variable target (Step 18.1) ───────────────────────────────────
// triggerClass === 'onSuccess' is guaranteed here for variable targets
// (any other trigger already continued above).
if (parsedVariableTarget) {
const { variableName } = parsedVariableTarget;
// Only declared variables may be targeted
if (!declaredVariableNames.has(variableName)) {
console.warn(
`[Preview] Binding "${binding.id}": target "variables.${variableName}" references ` +
`a variable that is not declared in project.variables. ` +
`Binding skipped. Declare the variable in project.variables first.`,
);
continue;
}
// Write to variable updates
variableUpdates[variableName] = sourceResult.value;
continue;
}
// ── 5b. Parse component target ─────────────────────────────────────────
if (!parsedTarget) {
console.warn(
`[Preview] Binding "${binding.id}": target "${binding.target}" is not a supported ` +
`component path (components.<name>.<property>) or variable path (variables.<name>).`,
);
continue;
}
// ── 5. Resolve target component ────────────────────────────────────────
const candidates = byName.get(parsedTarget.componentName);
if (!candidates || candidates.length === 0) {
console.warn(
`[Preview] Binding "${binding.id}": target component "${parsedTarget.componentName}" ` +
`does not exist on any page.`,
);
continue;
}
if (candidates.length > 1) {
const ids = candidates.map((c) => c.id).join(', ');
console.warn(
`[Preview] Binding "${binding.id}": target component name ` +
`"${parsedTarget.componentName}" is ambiguous — ${candidates.length} components share ` +
`this name (ids: ${ids}). No component updated.`,
);
continue;
}
const targetComp = candidates[0];
// ── 6. Confirm component type supports the target property ────────────
if (!isTargetPropertySupported(targetComp.type, parsedTarget.property)) {
console.warn(
`[Preview] Binding "${binding.id}": target property "${parsedTarget.property}" is not ` +
`supported for component type "${targetComp.type}". ` +
`Supported: .value → JsonViewer, Label, TextArea, Checkbox, RadioGroup, StatusPanel; .options → Dropdown; .rows → Table. Binding skipped.`,
);
continue;
}
// ── 7. Write to component state ────────────────────────────────────────
if (parsedTarget.property === 'rows') {
// Rows target — normalize the source array into TableRow[]
const normalizeResult = normalizeTableRows(sourceResult.value);
if (!normalizeResult.ok) {
console.warn(
`[Preview] Binding "${binding.id}": rows normalization failed — ` +
`${normalizeResult.reason} Table "${parsedTarget.componentName}" unchanged.`,
);
continue;
}
const existingUpdate = componentUpdates[targetComp.id] ?? currentComponentState[targetComp.id] ?? {};
componentUpdates[targetComp.id] = {
...existingUpdate,
rows: normalizeResult.rows,
// Clear row selection — old selection may no longer be valid
selectedIndex: undefined,
selectedRow: undefined,
loading: false,
error: undefined,
};
} else if (parsedTarget.property === 'options') {
// Options target — normalize the source array, reconcile selection
const normalizeResult = normalizeDropdownOptions(sourceResult.value);
if (!normalizeResult.ok) {
console.warn(
`[Preview] Binding "${binding.id}": options normalization failed — ` +
`${normalizeResult.reason} Dropdown "${parsedTarget.componentName}" unchanged.`,
);
continue;
}
for (const warning of normalizeResult.warnings) {
console.warn(`[Preview] Binding "${binding.id}" options: ${warning}`);
}
const { options: normalizedOptions } = normalizeResult;
// Determine configured value for reconciliation
const configuredValue =
typeof targetComp.properties['value'] === 'string'
? (targetComp.properties['value'] as string)
: '';
// Merge with any in-progress updates for this component
const existingUpdate = componentUpdates[targetComp.id] ?? currentComponentState[targetComp.id] ?? {};
const reconciledValue = reconcileDropdownSelection(
existingUpdate.value,
configuredValue,
normalizedOptions,
);
componentUpdates[targetComp.id] = {
...existingUpdate,
options: normalizedOptions,
value: reconciledValue,
loading: false,
error: undefined,
};
} else {
// Value target (display and input components)
componentUpdates[targetComp.id] = {
...(componentUpdates[targetComp.id] ?? {}),
value: sourceResult.value,
loading: false,
error: undefined,
};
}
}
return { componentUpdates, variableUpdates };
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
const [componentState, setComponentState] = useState<Record<string, ComponentRuntimeState>>({});
const [buttonLoading, setButtonLoading] = useState<Record<string, boolean>>({});
const [actionState, setActionState] = useState<ActionRuntimeStateMap>({});
// ── Step 18.1: Runtime variable state ─────────────────────────────────────
// Separate from project.variables — never mutates the canonical document.
// Initialized from defaultValue on mount; reset when doc.project.variables changes.
const [variableState, setVariableState] = useState<VariableRuntimeState>(
() => initializeVariableState(doc.project.variables),
);
// Re-initialize if the variable configuration changes (e.g. JSON editor update).
// Use a ref to compare identity so we don't re-run on every render.
const variablesRef = useRef(doc.project.variables);
useEffect(() => {
if (variablesRef.current !== doc.project.variables) {
variablesRef.current = doc.project.variables;
setVariableState(initializeVariableState(doc.project.variables));
}
}, [doc.project.variables]);
const handleTextInputChange = useCallback((componentId: string, value: string) => {
setComponentState((prev) => ({
...prev,
[componentId]: { ...prev[componentId], textValue: value },
}));
}, []);
const handleValueChange = useCallback((componentId: string, value: unknown) => {
setComponentState((prev) => ({
...prev,
[componentId]: { ...prev[componentId], value },
}));
}, []);
const handleButtonClick = useCallback(
(buttonId: string) => {
const { pages, actions, bindings, variables } = doc.project;
// Collect all components across all pages
const allComponents = pages.flatMap((p) => p.components);
// Build a name → id map for template interpolation (Step 16)
const componentsByName = new Map<string, string>(
allComponents.map((c) => [c.name, c.id]),
);
// Build declared variable names set for template interpolation (Step 18.1)
const currentDeclaredNames = new Set(Object.keys(variables));
// ── 1. Find the clicked button component ─────────────────────────────
const buttonComponent = allComponents.find((c) => c.id === buttonId);
if (!buttonComponent) {
setComponentState((prev) => ({
...prev,
[buttonId]: { error: `Component "${buttonId}" not found in project.` },
}));
return;
}
// ── 2. Read the component's events[] for onClick handlers ────────────
const clickEvents = (buttonComponent.events ?? []).filter(
(e) => e.event === 'onClick',
);
if (clickEvents.length === 0) {
setComponentState((prev) => ({
...prev,
[buttonId]: {
error:
`Button "${buttonComponent.name}" has no onClick event configured. ` +
`Add an events entry: { "event": "onClick", "actionId": "<actionId>" }`,
},
}));
return;
}
// Execute each onClick action (in practice there is usually one)
for (const clickEvent of clickEvents) {
const actionId = clickEvent.actionId;
// ── 3. Find the REST action ────────────────────────────────────────
const action: RestAction | undefined = actions.find((a) => a.id === actionId);
if (!action) {
setComponentState((prev) => ({
...prev,
[buttonId]: {
error:
`onClick event references action "${actionId}" which does not ` +
`exist in project.actions.`,
},
}));
continue;
}
// ── 4. Find response-mapping bindings for this action ────────────
// Identify target component IDs for loading-state marking.
// (Actual binding execution happens after the proxy call.)
const responseMappingTargetIds = new Set<string>();
for (const binding of bindings) {
const parsed = parseActionSourcePath(binding.source);
if (!parsed || parsed.actionId !== actionId) continue;
const tc = classifyTrigger(binding.trigger);
if (tc === 'unsupported') continue;
const parsedTarget = parseComponentTargetPath(binding.target);
if (!parsedTarget) continue;
const candidates = allComponents.filter((c) => c.name === parsedTarget.componentName);
if (candidates.length === 1 && isTargetPropertySupported(candidates[0].type, parsedTarget.property)) {
responseMappingTargetIds.add(candidates[0].id);
}
}
// ── 5. Mark loading state ──────────────────────────────────────────
setButtonLoading((prev) => ({ ...prev, [buttonId]: true }));
setActionState((prev) => ({
...prev,
[actionId]: { ...prev[actionId], loading: true, error: undefined },
}));
for (const targetId of responseMappingTargetIds) {
setComponentState((prev) => ({
...prev,
[targetId]: { ...prev[targetId], loading: true, error: undefined },
}));
}
// ── 6. Render templates and execute (Steps 16, 18.1) ──────────────
let renderedAction: RestAction;
try {
renderedAction = renderAction(action, componentsByName, componentState, variableState, currentDeclaredNames);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setComponentState((prev) => ({
...prev,
[buttonId]: { loading: false, error: message },
}));
setActionState((prev) => ({
...prev,
[actionId]: { loading: false, error: message },
}));
setButtonLoading((prev) => ({ ...prev, [buttonId]: false }));
continue;
}
// ── 7. Execute proxy request ──────────────────────────────────────
executeAction(renderedAction)
.then((proxyResponse) => {
// Store full response envelope in action state
const newActionEntry: ActionRuntimeState = {
response: proxyResponse,
loading: false,
error: undefined,
};
const updatedActionState: ActionRuntimeStateMap = {
[actionId]: newActionEntry,
};
setActionState((prev) => ({ ...prev, ...updatedActionState }));
// ── 8. Apply response-mapping bindings ─────────────────────
if (proxyResponse.ok) {
const { componentUpdates, variableUpdates } = applyResponseBindings(
actionId,
proxyResponse,
bindings,
allComponents,
updatedActionState,
componentState,
currentDeclaredNames,
);
if (Object.keys(componentUpdates).length > 0) {
setComponentState((prev) => ({ ...prev, ...componentUpdates }));
}
if (Object.keys(variableUpdates).length > 0) {
setVariableState((prev) => ({ ...prev, ...variableUpdates }));
}
// If no bindings mapped anywhere, surface the full response on
// the button itself so the user sees feedback even without a binding.
if (Object.keys(componentUpdates).length === 0 && Object.keys(variableUpdates).length === 0 && responseMappingTargetIds.size === 0) {
setComponentState((prev) => ({
...prev,
[buttonId]: { value: proxyResponse, loading: false, error: undefined },
}));
}
} else {
// Non-2xx from upstream: surface status on button as an error message
const errMsg =
`Action "${action.name}" returned HTTP ${proxyResponse.status} ` +
`${proxyResponse.statusText}.`;
setComponentState((prev) => ({
...prev,
[buttonId]: { loading: false, error: errMsg },
}));
// Clear loading on mapped targets
const clearedTargets: Record<string, ComponentRuntimeState> = {};
for (const tid of responseMappingTargetIds) {
clearedTargets[tid] = { loading: false };
}
if (Object.keys(clearedTargets).length > 0) {
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
}
}
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
setActionState((prev) => ({
...prev,
[actionId]: { loading: false, error: message },
}));
setComponentState((prev) => ({
...prev,
[buttonId]: { loading: false, error: message },
}));
// Clear loading on mapped targets
const clearedTargets: Record<string, ComponentRuntimeState> = {};
for (const tid of responseMappingTargetIds) {
clearedTargets[tid] = { loading: false };
}
if (Object.keys(clearedTargets).length > 0) {
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
}
})
.finally(() => {
setButtonLoading((prev) => ({ ...prev, [buttonId]: false }));
});
}
},
[doc.project, componentState, variableState],
);
/**
* Dropdown onChange handler.
*
* 1. Stores the selected option value in componentState[id].value so:
* - DropdownRenderer reflects the selection immediately
* - {{components.<name>.value}} template interpolation resolves it
*
* 2. Fires any onChange component events declared on the Dropdown, using the
* same action-execution path as handleButtonClick. This reuses the
* existing proxy call logic — only the triggering event differs.
*/
const handleDropdownChange = useCallback(
(dropdownId: string, selectedValue: string) => {
// 1. Update runtime state immediately
setComponentState((prev) => ({
...prev,
[dropdownId]: { ...prev[dropdownId], value: selectedValue },
}));
// 2. Fire onChange component events
const { pages, actions, bindings, variables } = doc.project;
const allComponents = pages.flatMap((p) => p.components);
const dropdownComponent = allComponents.find((c) => c.id === dropdownId);
if (!dropdownComponent) return;
const changeEvents = (dropdownComponent.events ?? []).filter(
(e) => e.event === 'onChange',
);
if (changeEvents.length === 0) return;
const componentsByName = new Map<string, string>(
allComponents.map((c) => [c.name, c.id]),
);
const dropdownDeclaredNames = new Set(Object.keys(variables));
for (const changeEvent of changeEvents) {
const actionId = changeEvent.actionId;
const action = actions.find((a) => a.id === actionId);
if (!action) {
console.warn(
`[Preview] Dropdown "${dropdownComponent.name}" onChange event references ` +
`action "${actionId}" which does not exist in project.actions.`,
);
continue;
}
// Build the component state snapshot including the just-selected value
// so template interpolation sees the new selection.
const stateWithSelection = {
...componentState,
[dropdownId]: { ...(componentState[dropdownId] ?? {}), value: selectedValue },
};
// Identify response-mapping targets for loading state
const responseMappingTargetIds = new Set<string>();
for (const binding of bindings) {
const parsed = parseActionSourcePath(binding.source);
if (!parsed || parsed.actionId !== actionId) continue;
const tc = classifyTrigger(binding.trigger);
if (tc === 'unsupported') continue;
const parsedTarget = parseComponentTargetPath(binding.target);
if (!parsedTarget) continue;
const candidates = allComponents.filter((c) => c.name === parsedTarget.componentName);
if (candidates.length === 1 && isTargetPropertySupported(candidates[0].type, parsedTarget.property)) {
responseMappingTargetIds.add(candidates[0].id);
}
}
setActionState((prev) => ({
...prev,
[actionId]: { ...prev[actionId], loading: true, error: undefined },
}));
for (const tid of responseMappingTargetIds) {
setComponentState((prev) => ({
...prev,
[tid]: { ...prev[tid], loading: true, error: undefined },
}));
}
let renderedAction: RestAction;
try {
renderedAction = renderAction(action, componentsByName, stateWithSelection, variableState, dropdownDeclaredNames);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setActionState((prev) => ({
...prev,
[actionId]: { loading: false, error: message },
}));
continue;
}
executeAction(renderedAction)
.then((proxyResponse) => {
const newActionEntry: ActionRuntimeState = {
response: proxyResponse,
loading: false,
error: undefined,
};
const updatedActionState: ActionRuntimeStateMap = { [actionId]: newActionEntry };
setActionState((prev) => ({ ...prev, ...updatedActionState }));
if (proxyResponse.ok) {
const { componentUpdates, variableUpdates } = applyResponseBindings(
actionId,
proxyResponse,
bindings,
allComponents,
updatedActionState,
stateWithSelection,
dropdownDeclaredNames,
);
if (Object.keys(componentUpdates).length > 0) {
setComponentState((prev) => ({ ...prev, ...componentUpdates }));
}
if (Object.keys(variableUpdates).length > 0) {
setVariableState((prev) => ({ ...prev, ...variableUpdates }));
}
} else {
const clearedTargets: Record<string, ComponentRuntimeState> = {};
for (const tid of responseMappingTargetIds) {
clearedTargets[tid] = { loading: false };
}
if (Object.keys(clearedTargets).length > 0) {
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
}
}
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
setActionState((prev) => ({
...prev,
[actionId]: { loading: false, error: message },
}));
const clearedTargets: Record<string, ComponentRuntimeState> = {};
for (const tid of responseMappingTargetIds) {
clearedTargets[tid] = { loading: false };
}
if (Object.keys(clearedTargets).length > 0) {
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
}
});
}
},
[doc.project, componentState, variableState],
);
const handleTableRowSelect = useCallback(
(tableId: string, index: number, row: Record<string, unknown>) => {
setComponentState((prev) => ({
...prev,
[tableId]: { ...prev[tableId], selectedIndex: index, selectedRow: row },
}));
},
[],
);
return {
componentState,
buttonLoading,
actionState,
variableState,
handleButtonClick,
handleTextInputChange,
handleDropdownChange,
handleTableRowSelect,
handleValueChange,
};
}