840 lines
35 KiB
TypeScript
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,
|
|
};
|
|
}
|