/** * 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": "" } * Declares that clicking this component fires the named action. * * 2. Project-level bindings (project.bindings[]): * { "id": "...", "source": "actions..response.body", * "target": "components..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..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..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..value (display and input components) * variables. (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.}} 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; }; export type PreviewRuntime = { /** * Map from component ID → ephemeral runtime state. * Consumers read this to override design-time defaultValue / label. */ componentState: Record; /** * Loading state keyed by Button component ID. * True while the button's bound action is in-flight. */ buttonLoading: Record; /** * 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.. * 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..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) => 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; variableUpdates: VariableRuntimeState; }; function applyResponseBindings( actionId: string, proxyResponse: ProxyResponse, bindings: Binding[], allComponents: CanvasComponent[], updatedActionState: ActionRuntimeStateMap, currentComponentState: Record, declaredVariableNames: Set, ): ApplyResponseBindingsResult { // Build name → component map (used for target resolution) const byName = new Map(); for (const c of allComponents) { const list = byName.get(c.name) ?? []; list.push(c); byName.set(c.name, list); } const componentUpdates: Record = {}; 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..) or variable path (variables.).`, ); 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>({}); const [buttonLoading, setButtonLoading] = useState>({}); const [actionState, setActionState] = useState({}); // ── 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( () => 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( 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": "" }`, }, })); 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(); 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 = {}; 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 = {}; 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..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( 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(); 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 = {}; 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 = {}; 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) => { setComponentState((prev) => ({ ...prev, [tableId]: { ...prev[tableId], selectedIndex: index, selectedRow: row }, })); }, [], ); return { componentState, buttonLoading, actionState, variableState, handleButtonClick, handleTextInputChange, handleDropdownChange, handleTableRowSelect, handleValueChange, }; }