/** * Action and Binding Inspector — Step 15.5 / 15.6 / 16.5 / 17.1 / 17.3 / 17.4 / 17.5 / 18.1 * * Visual REST action authoring plus binding inspection for the current project, * synchronized through the canonical document in ProjectContext. * * ── Diagnostics computed ────────────────────────────────────────────────────── * Per action (Step 15.6): * • Action is not triggered by any component or page event (info) * Per action (Step 16.5 / 18.1): * • Template references a component that does not exist (warning) * • Template uses unsupported property (not "value") (warning) * • Template references a variable that does not exist (warning) ← 18.1 * • Malformed template syntax (unclosed {{) (warning) * * Per binding (Step 15.6 + 17.1 + 18.1): * • Source references an action that does not exist (warning) * • Source path grammar is not a supported action-response path (warning) * • Target references a component that does not exist (warning) * • Target component name is duplicated on a page (warning) * • Target property not supported for the component type (warning) * (.value → JsonViewer/Label; .options → Dropdown; .rows → Table) * • Target is a variable that is not declared (warning) ← 18.1 * • Trigger is unsupported for variable-target bindings (warning) ← 18.1 * • No component or page event fires the source action (warning) * • Trigger is unsupported for response bindings (warning) * • Trigger "onClick" is legacy — recommend "onSuccess" (info) * * Per component event (surfaced inside binding diagnostics): * • Event actionId references a missing action (warning) * * Per Table component (Step 17.4): * • Duplicate column keys (warning) * • Empty column key (warning) * • Binding targeting Table.rows / Table.value (warning) */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useProject } from '../../context/ProjectContext'; import { executeAction } from '../../api/proxyApi'; import type { ProxyResponse } from '../../api/proxyApi'; import type { RestAction, Binding, CanvasComponent, DropdownOption, Page, TableColumn, Variable } from '../../types/project'; import { extractTemplates, classifyVariableExpression } from '../Preview/templateUtils'; import { isTargetPropertySupported, classifyTrigger, parseActionSourcePath, parseComponentTargetPath, parseVariableTargetPath, } from '../Preview/bindingUtils'; import styles from './ActionInspector.module.css'; import RestActionEditor from './RestActionEditor'; import ResponseBindingEditor from './ResponseBindingEditor'; import VariableEditor from './VariableEditor'; import SecretManager from './SecretManager'; import { listSecrets } from '../../api/secretsApi'; import type { SecretMetadata } from '../../api/secretsApi'; import ValidationSummary from '../ValidationSummary'; import { createResponseBinding, findVariableReferences, removeBindingAt, removeVariable, replaceBindingAt, responseTargetOptions, setVariable, } from './configurationUtils'; import { appendRestAction, buildDeleteConfirmation, collectReferencedActionIds, createRestAction, duplicateRestAction, findActionReferences, removeRestActionAt, replaceRestActionAt, validateRestAction, } from './actionEditorUtils'; // ── Diagnostic types ────────────────────────────────────────────────────────── type DiagSeverity = 'warn' | 'info'; type Diagnostic = { severity: DiagSeverity; message: string; }; /** * All diagnostics indexed by a stable render subject so cards can look up their own. * Keys: action indices (isolating duplicate IDs), binding IDs, and synthetic subjects. * Also a special key "__componentEvents" for component-event issues not tied to * a specific binding (currently unused — shown on binding cards that reference * the missing action). */ type DiagMap = Record; function actionDiagnosticKey(actionIndex: number): string { return `__action_${actionIndex}`; } // ── Pure diagnostic computation ─────────────────────────────────────────────── /** * Computes all diagnostics from the project in a single pass. * Never throws — any unexpected input is handled gracefully. * Returns a DiagMap keyed by action index, binding ID, or synthetic subject. */ function computeDiagnostics( actions: RestAction[], bindings: Binding[], pages: Page[], allComponents: CanvasComponent[], variables: Record, ): DiagMap { const declaredVariableNames = new Set(Object.keys(variables)); const map: DiagMap = {}; function add(key: string, severity: DiagSeverity, message: string) { if (!map[key]) map[key] = []; map[key].push({ severity, message }); } const actionIds = new Set(actions.map((a) => a.id)); const componentsByName = new Map(allComponents.map((c) => [c.name, c])); // ── Index: which actionIds are triggered by a component or page event ───── const triggeredActionIds = new Set(); for (const page of pages) { for (const ev of page.events ?? []) { triggeredActionIds.add(ev.actionId); } } for (const comp of allComponents) { for (const ev of comp.events ?? []) { triggeredActionIds.add(ev.actionId); } } // ── Index: for a given actionId, which components have an onClick event ─── // key: actionId → component names that fire it via onClick const onClickTriggers = new Map(); for (const comp of allComponents) { for (const ev of comp.events ?? []) { if (ev.event === 'onClick') { const list = onClickTriggers.get(ev.actionId) ?? []; list.push(comp.name); onClickTriggers.set(ev.actionId, list); } } } // ── 1. Diagnostics per action ───────────────────────────────────────────── actions.forEach((action, actionIndex) => { const diagnosticKey = actionDiagnosticKey(actionIndex); for (const issue of validateRestAction(action, actions)) { add(diagnosticKey, issue.severity, issue.message); } // 1a. Untriggered action if (!triggeredActionIds.has(action.id)) { add( diagnosticKey, 'info', `Action "${action.name}" is not triggered by any component or page event. ` + `Assign it to a supported component event or to a page onLoad event.`, ); } // 1b. Template diagnostics (Step 16.5) const { tokens, malformed } = extractTemplates(action); for (const token of tokens) { if (token.namespace === 'variables') { if (!declaredVariableNames.has(token.name)) { add( diagnosticKey, 'warn', `Template "${token.raw}" at ${token.location} references variable ` + `"${token.name}" which is not declared in project.variables.`, ); } continue; } // Component token — check property and existence if (token.propertyName !== 'value') { add( diagnosticKey, 'warn', `Template "${token.raw}" at ${token.location} references property ` + `"${token.propertyName ?? '?'}". Only "value" is supported. ` + `Use {{components..value}}.`, ); continue; } // Missing component if (token.componentName && !componentsByName.has(token.componentName)) { add( diagnosticKey, 'warn', `Template "${token.raw}" at ${token.location} references component ` + `"${token.componentName}" which does not exist on any page.`, ); } } for (const bad of malformed) { const isMalformedVar = classifyVariableExpression(bad.raw) === 'malformed'; add( diagnosticKey, 'warn', isMalformedVar ? `Malformed variable template "${bad.raw}" at ${bad.location}: ` + `expected {{variables.}} where contains no dots. ` + `The action cannot execute while this expression is present.` : `Malformed template at ${bad.location}: "${bad.raw}…" — ` + `missing closing "}}". Check your template syntax.`, ); } }); // ── Index: component name → all components with that name (duplicate check) ─ const componentsByNameAll = new Map(); for (const comp of allComponents) { const list = componentsByNameAll.get(comp.name) ?? []; list.push(comp); componentsByNameAll.set(comp.name, list); } // ── 2. Diagnostics per binding ──────────────────────────────────────────── for (const binding of bindings) { // Parse source using bindingUtils (handles extended paths like .response.body.field) const parsedSource = parseActionSourcePath(binding.source); // Legacy: also handle component-sourced bindings (non-action sources) const sourceComponentMatch = /^components\.([^.]+)\./.exec(binding.source); const sourceComponentName = sourceComponentMatch ? sourceComponentMatch[1] : null; const sourceActionId = parsedSource?.actionId ?? null; // Parse targets (both component and variable) const parsedTarget = parseComponentTargetPath(binding.target); const parsedVariableTarget = parseVariableTargetPath(binding.target); const targetComponentName = parsedTarget?.componentName ?? null; // 2a. Source action missing if (sourceActionId && !actionIds.has(sourceActionId)) { add( binding.id, 'warn', `Source references action "${sourceActionId}" which does not exist in project.actions.`, ); } // 2a2. Source begins with "actions." but doesn't parse as a valid response path if (binding.source.startsWith('actions.') && !parsedSource) { add( binding.id, 'warn', `Source "${binding.source}" does not follow the supported action-response path grammar. ` + `Expected: actions..response[.body[.]]`, ); } // 2b. Source component missing (for component-sourced bindings) if (sourceComponentName && !componentsByName.has(sourceComponentName)) { add( binding.id, 'warn', `Source references component "${sourceComponentName}" which does not exist on any page.`, ); } if (sourceComponentName && componentsByName.has(sourceComponentName)) { const candidates = componentsByNameAll.get(sourceComponentName) ?? []; if (candidates.length === 1 && (candidates[0].type !== 'Table' || binding.source !== `components.${sourceComponentName}.selectedRow`)) { add(binding.id, 'warn', 'Component binding sources must use components..selectedRow on a Table component.'); } if ((binding.trigger ?? 'onChange') !== 'onChange') { add(binding.id, 'warn', 'Table selection bindings require trigger "onChange".'); } } // 2c. Variable target diagnostics (Step 18.1) if (parsedVariableTarget) { const { variableName } = parsedVariableTarget; // 2c1. Variable must be declared if (!declaredVariableNames.has(variableName)) { add( binding.id, 'warn', `Target "variables.${variableName}" references a variable that is not declared ` + `in project.variables. Declare the variable before using it as a binding target.`, ); } // 2c2. Variable response bindings require onSuccess — onClick (legacy) is // not accepted for variable targets (unlike component targets). if (sourceActionId) { const triggerClass = classifyTrigger(binding.trigger); if (triggerClass !== 'onSuccess') { add( binding.id, 'warn', `Variable response bindings require trigger "onSuccess". ` + `Got "${binding.trigger ?? 'onChange'}". ` + `Change the trigger to "onSuccess".`, ); } } } else if (!parsedTarget) { // 2d. Target path doesn't start with "components." or "variables." if (!binding.target.startsWith('components.') && !binding.target.startsWith('variables.')) { add( binding.id, 'warn', `Target "${binding.target}" is not a recognised path. ` + `Use "components.." or "variables.".`, ); } else { // Starts with a recognised prefix but didn't parse — malformed add( binding.id, 'warn', `Target "${binding.target}" could not be parsed. ` + `For components use "components.."; for variables use "variables.".`, ); } } // 2d. Target component missing if (targetComponentName) { const candidates = componentsByNameAll.get(targetComponentName) ?? []; if (candidates.length === 0) { add( binding.id, 'warn', `Target references component "${targetComponentName}" which does not exist on any page.`, ); } else if (candidates.length > 1) { add( binding.id, 'warn', `Target component name "${targetComponentName}" is ambiguous — ` + `${candidates.length} components share this name. Binding resolution will fail at runtime.`, ); } else { const comp = candidates[0]; // 2d2. Target property + component type must be a supported combination if (parsedTarget && !isTargetPropertySupported(comp.type, parsedTarget.property)) { add( binding.id, 'warn', `Target property "${parsedTarget.property}" is not supported for ` + `component type "${comp.type}". ` + `Supported: .value → JsonViewer, Label; .options → Dropdown; .rows → Table.`, ); } } } // 2e. No component or page event fires the source action at all if (sourceActionId && actionIds.has(sourceActionId) && !triggeredActionIds.has(sourceActionId)) { add( binding.id, 'warn', `No component or page event fires action "${sourceActionId}". ` + `The binding will never receive a response. ` + `Assign the action to a supported component event or to a page onLoad event.`, ); } // 2f. Trigger classification for component targets (Step 17.1) // Variable targets already had trigger checked in 2c — skip to avoid duplicates. if (sourceActionId && !parsedVariableTarget) { const triggerClass = classifyTrigger(binding.trigger); if (triggerClass === 'unsupported') { add( binding.id, 'warn', `Trigger "${binding.trigger ?? 'onChange'}" is not supported for action-response ` + `bindings. Use "onSuccess" (or "onClick" for legacy compatibility).`, ); } else if (triggerClass === 'onClick-legacy') { add( binding.id, 'info', `Trigger "onClick" is a legacy response-mapping trigger. ` + `Consider migrating to "onSuccess".`, ); } } } // ── 3. Diagnostics from component events (shown on bindings) ───────────── // For each component event that references a missing action, find the // bindings that reference that action and add the warning there. // If no such binding exists, add a synthetic entry keyed by // "__event__". for (const comp of allComponents) { for (const ev of comp.events ?? []) { if (!actionIds.has(ev.actionId)) { // Find bindings that reference this action (any source path form) const relatedBindings = bindings.filter((b) => { const parsed = parseActionSourcePath(b.source); return parsed ? parsed.actionId === ev.actionId : false; }); const msg = `Component "${comp.name}" has event "${ev.event}" referencing ` + `action "${ev.actionId}" which does not exist in project.actions.`; if (relatedBindings.length > 0) { for (const b of relatedBindings) add(b.id, 'warn', msg); } else { // No binding references this event — key by component+action add(`__event_${comp.name}_${ev.actionId}`, 'warn', msg); } } } } // ── 4. Diagnostics per Table component ─────────────────────────────────── for (const comp of allComponents) { if (comp.type !== 'Table') continue; const rawColumns = comp.properties.columns; const columns: TableColumn[] = Array.isArray(rawColumns) ? (rawColumns as TableColumn[]).filter( (c) => c && typeof c.key === 'string' && typeof c.header === 'string', ) : []; // 4a. Duplicate column keys const colKeys = columns.map((c) => c.key).filter(Boolean); const seenKeys = new Set(); const dupKeys = new Set(); for (const k of colKeys) { if (seenKeys.has(k)) dupKeys.add(k); seenKeys.add(k); } if (dupKeys.size > 0) { add( `__table_${comp.name}`, 'warn', `Table "${comp.name}" has duplicate column keys: ` + `${[...dupKeys].map((k) => `"${k}"`).join(', ')}.`, ); } // 4b. Empty column key if (columns.some((c) => !c.key)) { add( `__table_${comp.name}`, 'warn', `Table "${comp.name}" has a column with an empty key. Edit columns in the property editor.`, ); } } // ── 5. Diagnostics per Dropdown component ──────────────────────────────── for (const comp of allComponents) { if (comp.type !== 'Dropdown') continue; const rawOptions = comp.properties.options; const options: DropdownOption[] = Array.isArray(rawOptions) ? (rawOptions as DropdownOption[]).filter( (o) => o && typeof o.label === 'string' && typeof o.value === 'string', ) : []; // 4a. Duplicate option values const optionValues = options.map((o) => o.value).filter(Boolean); const seenValues = new Set(); const dupValues = new Set(); for (const v of optionValues) { if (seenValues.has(v)) dupValues.add(v); seenValues.add(v); } if (dupValues.size > 0) { add( `__dropdown_${comp.name}`, 'warn', `Dropdown "${comp.name}" has duplicate option values: ` + `${[...dupValues].map((v) => `"${v}"`).join(', ')}. ` + `Runtime selection may be ambiguous.`, ); } // 4b. Configured value not in options const configuredValue = typeof comp.properties.value === 'string' ? comp.properties.value : ''; if (configuredValue !== '' && !options.some((o) => o.value === configuredValue)) { add( `__dropdown_${comp.name}`, 'warn', `Dropdown "${comp.name}" configured value "${configuredValue}" is not present ` + `in its options list. Preview will show the placeholder instead.`, ); } // 4c. Options with empty label or value for (const opt of options) { if (!opt.label || !opt.value) { add( `__dropdown_${comp.name}`, 'warn', `Dropdown "${comp.name}" has an option with an empty ` + `${!opt.label ? 'label' : 'value'}. ` + `Edit options in the property editor.`, ); break; // one warning is enough } } } return map; } // ── Helpers ─────────────────────────────────────────────────────────────────── function methodClass(method: string): string { switch (method.toUpperCase()) { case 'GET': return styles.methodGet; case 'POST': return styles.methodPost; case 'PUT': return styles.methodPut; case 'PATCH': return styles.methodPatch; case 'DELETE': return styles.methodDelete; default: return styles.methodOther; } } function parseActionSource(expr: string): string | null { return parseActionSourcePath(expr)?.actionId ?? null; } function parseComponentExpr(expr: string): string | null { const m = /^components\.([^.]+)\./.exec(expr); return m ? m[1] : null; } const AUTH_LABELS: Record = { anonymous: 'Anonymous', bearerToken: 'Bearer Token', basicAuth: 'Basic Auth', apiKeyHeader: 'API Key (Header)', apiKeyQueryParameter: 'API Key (Query)', }; // ── DiagList sub-component ──────────────────────────────────────────────────── function DiagList({ diags }: { diags: Diagnostic[] }): React.ReactElement | null { if (diags.length === 0) return null; return (
{diags.map((d, i) => ( d.severity === 'warn' ? (
{d.message}
) : (
{d.message}
) ))}
); } // ── ActionCard ──────────────────────────────────────────────────────────────── type TestState = | { status: 'idle' } | { status: 'running' } | { status: 'ok'; response: ProxyResponse } | { status: 'error'; message: string }; type ActionCardProps = { action: RestAction; diags: Diagnostic[]; allComponents: CanvasComponent[]; isEditing: boolean; actionsLocked: boolean; expanded: boolean; onToggle: () => void; onEdit: () => void; onDuplicate: () => void; onDelete: () => void; }; function ActionCard({ action, diags, allComponents, isEditing, actionsLocked, expanded, onToggle, onEdit, onDuplicate, onDelete, }: ActionCardProps): React.ReactElement { const [test, setTest] = useState({ status: 'idle' }); const requestGeneration = useRef(0); useEffect(() => { requestGeneration.current += 1; setTest({ status: 'idle' }); return () => { requestGeneration.current += 1; }; }, [action]); const handleTest = useCallback(async () => { const generation = ++requestGeneration.current; setTest({ status: 'running' }); try { const response = await executeAction(action); if (generation !== requestGeneration.current) return; setTest({ status: 'ok', response }); } catch (err) { if (generation !== requestGeneration.current) return; setTest({ status: 'error', message: err instanceof Error ? err.message : String(err), }); } }, [action]); const controlsLocked = actionsLocked || test.status === 'running'; // Compute template info for display const { tokens, malformed } = useMemo( () => extractTemplates(action), [action], ); const componentsByName = useMemo( () => new Map(allComponents.map((c) => [c.name, c])), [allComponents], ); const hasTemplates = tokens.length > 0 || malformed.length > 0; return (
{expanded &&
{action.description && {action.description}} {/* ── Template references (Step 16.5) ── */} {hasTemplates && (
Template References
{tokens.map((token, i) => { const comp = token.componentName ? componentsByName.get(token.componentName) : null; const resolved = comp !== null && comp !== undefined; const unsupportedProp = token.propertyName !== 'value'; return (
{token.raw} {token.location} {unsupportedProp ? ( unsupported property “{token.propertyName}” ) : resolved ? ( {comp!.name} / {comp!.type} ) : ( component “{token.componentName}” not found )}
); })} {malformed.map((bad, i) => (
{bad.raw}… {bad.location} malformed — missing {{}} closing braces
))}
)} {/* ── Diagnostics ── */}
{/* ── Test Action ── */}
{test.status === 'running' && ( Calling proxy… )} {test.status === 'ok' && (
HTTP {test.response.status} {test.response.statusText} {' · '}{test.response.durationMs} ms {' · '}{test.response.ok ? '✓ ok' : '✗ non-2xx'}
              {JSON.stringify(test.response.body, null, 2)}
            
)} {test.status === 'error' && (
              {test.message}
            
)}
}
); } // ── BindingCard ─────────────────────────────────────────────────────────────── type BindingCardProps = { binding: Binding; allComponents: CanvasComponent[]; allActions: RestAction[]; diags: Diagnostic[]; expanded: boolean; onToggle: () => void; onEdit: () => void; onDelete: () => void; }; function BindingCard({ binding, allComponents, allActions, diags, expanded, onToggle, onEdit, onDelete, }: BindingCardProps): React.ReactElement { // Resolve source const sourceActionId = parseActionSource(binding.source); const sourceAction = sourceActionId ? allActions.find((a) => a.id === sourceActionId) : null; const sourceComponentName = parseComponentExpr(binding.source); const sourceComponent = sourceComponentName ? allComponents.find((c) => c.name === sourceComponentName) : null; // Resolve target const targetComponentName = parseComponentExpr(binding.target); const targetComponent = targetComponentName ? allComponents.find((c) => c.name === targetComponentName) : null; const targetVariableName = parseVariableTargetPath(binding.target)?.variableName ?? null; const trigger = binding.trigger ?? 'onChange'; return (
{expanded &&
{/* ── Source / Target flow ── */}
{/* Source */}
Source {binding.source} {sourceAction && ( {sourceAction.method} {sourceAction.name} )} {sourceComponent && ( {sourceComponent.name} / {sourceComponent.type} )} {!sourceAction && !sourceComponent && sourceActionId && ( action “{sourceActionId}” not found )} {!sourceAction && !sourceComponent && sourceComponentName && !sourceActionId && ( component “{sourceComponentName}” not found )}
{/* Target */}
Target {binding.target} {targetComponent ? ( {targetComponent.name} / {targetComponent.type} ) : targetComponentName ? ( component “{targetComponentName}” not found ) : targetVariableName ? ( Variable {targetVariableName} ) : null}
{/* ── Diagnostics ── */}
}
); } // ── Main component ──────────────────────────────────────────────────────────── function ActionInspector(): React.ReactElement { const { doc, setDoc } = useProject(); const { actions, bindings, pages, variables } = doc.project; const allComponents = useMemo( () => pages.flatMap((p) => p.components), [pages], ); const [editingActionIndex, setEditingActionIndex] = useState(null); const [editingBindingIndex, setEditingBindingIndex] = useState(null); const [editingVariableName, setEditingVariableName] = useState(null); const [addingVariable, setAddingVariable] = useState(false); const [secrets, setSecrets] = useState([]); const [openSections, setOpenSections] = useState(() => new Set(['actions'])); const [openActions, setOpenActions] = useState>(() => new Set()); const [openBindings, setOpenBindings] = useState>(() => new Set()); const [openVariables, setOpenVariables] = useState>(() => new Set()); const toggleOpen = useCallback((setter: React.Dispatch>>, key: string) => { setter((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const refreshSecrets = useCallback(async () => { try { setSecrets(await listSecrets()); } catch { setSecrets([]); } }, []); useEffect(() => { void refreshSecrets(); }, [refreshSecrets]); useEffect(() => { if ( editingActionIndex !== null && editingActionIndex >= actions.length ) { setEditingActionIndex(null); } }, [actions.length, editingActionIndex]); useEffect(() => { if (editingBindingIndex !== null && editingBindingIndex >= bindings.length) { setEditingBindingIndex(null); } }, [bindings.length, editingBindingIndex]); const handleAddAction = useCallback(() => { const action = createRestAction( actions, collectReferencedActionIds(doc), ); setDoc((current) => appendRestAction(current, action)); setEditingActionIndex(actions.length); setOpenActions((current) => new Set(current).add(`${action.id}-${actions.length}`)); }, [actions, doc, setDoc]); const handleUpdateAction = useCallback( (actionIndex: number, action: RestAction) => { setDoc((current) => replaceRestActionAt(current, actionIndex, action)); }, [setDoc], ); const handleDuplicateAction = useCallback( (source: RestAction) => { const duplicate = duplicateRestAction( source, actions, collectReferencedActionIds(doc), ); setDoc((current) => appendRestAction(current, duplicate)); setEditingActionIndex(actions.length); setOpenActions((current) => new Set(current).add(`${duplicate.id}-${actions.length}`)); }, [actions, doc, setDoc], ); const handleDeleteAction = useCallback( (actionIndex: number, action: RestAction) => { const references = findActionReferences(doc, action.id); if (!window.confirm(buildDeleteConfirmation(action, references))) return; setDoc((current) => removeRestActionAt(current, actionIndex)); setEditingActionIndex((current) => { if (current === null || current === actionIndex) return null; return current > actionIndex ? current - 1 : current; }); }, [doc, setDoc], ); const targetOptions = useMemo( () => responseTargetOptions(allComponents, variables), [allComponents, variables], ); const handleAddBinding = useCallback(() => { if (actions.length === 0 || targetOptions.length === 0) return; const binding = createResponseBinding(bindings, actions[0], targetOptions[0].value); setDoc((current) => ({ ...current, project: { ...current.project, bindings: [...current.project.bindings, binding] } })); setEditingBindingIndex(bindings.length); setOpenBindings((current) => new Set(current).add(`${binding.id}-${bindings.length}`)); }, [actions, bindings, setDoc, targetOptions]); const handleDeleteVariable = useCallback((name: string) => { const references = findVariableReferences(doc, name); const suffix = references.length > 0 ? `\n\nReferences will become unresolved:\n- ${references.join('\n- ')}` : ''; if (!window.confirm(`Delete variable "${name}"?${suffix}`)) return; setDoc((current) => removeVariable(current, name)); }, [doc, setDoc]); // Compute all diagnostics once per render cycle const diagMap = useMemo( () => computeDiagnostics(actions, bindings, pages, allComponents, variables), [actions, bindings, pages, allComponents, variables], ); // Count total warnings/infos for section summary banners const actionWarnings = actions.reduce( (count, _action, actionIndex) => count + ( diagMap[actionDiagnosticKey(actionIndex)] ?.filter((diagnostic) => diagnostic.severity === 'warn').length ?? 0 ), 0, ); const actionInfos = actions.reduce( (count, _action, actionIndex) => count + ( diagMap[actionDiagnosticKey(actionIndex)] ?.filter((diagnostic) => diagnostic.severity === 'info').length ?? 0 ), 0, ); const bindingWarnings = bindings.reduce( (n, b) => n + (diagMap[b.id]?.filter((d) => d.severity === 'warn').length ?? 0), 0, ); const totalIssues = actionWarnings + actionInfos + bindingWarnings; return (
{/* ── Page header ── */}
Actions & Bindings
Create and configure anonymous REST actions here without editing project JSON by hand. Valid edits synchronize with the canonical document immediately. Test Action calls the backend proxy with the configured action; diagnostics highlight invalid definitions and broken references.
{/* ══ Actions section ══════════════════════════════════════════ */}
{actions.length}
{/* ══ Bindings section ═════════════════════════════════════════ */}
{bindings.length}
{Object.keys(variables).length}
{/* ══ Overall summary (only shown when there are issues) ═══════ */} {totalIssues === 0 && (actions.length > 0 || bindings.length > 0) && (
✓ All actions and bindings are consistent.
)}
); } export default ActionInspector;