1240 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, Diagnostic[]>;
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<string, Variable>,
): 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<string>();
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<string, string[]>();
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.<name>.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.<name>}} where <name> 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<string, CanvasComponent[]>();
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.<actionId>.response[.body[.<field…>]]`,
);
}
// 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.<tableName>.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.<name>.<property>" or "variables.<name>".`,
);
} 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.<name>.<property>"; for variables use "variables.<name>".`,
);
}
}
// 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_<component.name>_<actionId>".
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<string>();
const dupKeys = new Set<string>();
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<string>();
const dupValues = new Set<string>();
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<string, string> = {
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 (
<div className={styles.diagList}>
{diags.map((d, i) => (
d.severity === 'warn' ? (
<div key={i} className={styles.diagWarn}>
<em className={styles.diagWarnIcon}></em>
<span>{d.message}</span>
</div>
) : (
<div key={i} className={styles.diagInfo}>
<em className={styles.diagInfoIcon}></em>
<span>{d.message}</span>
</div>
)
))}
</div>
);
}
// ── 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<TestState>({ 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 (
<div className={styles.actionCard}>
<button type="button" className={styles.cardToggle} onClick={onToggle} aria-expanded={expanded} aria-label={`${expanded ? 'Collapse' : 'Expand'} REST action ${action.name}`}>
<span className={styles.toggleHeadingRow}>
<span className={styles.recordType}>REST action</span>
<span className={styles.toggleStatus}>
{diags.length > 0 && <span className={styles.issueBadge}>{diags.length} issue{diags.length === 1 ? '' : 's'}</span>}
<span className={styles.chevron} aria-hidden="true">{expanded ? '▾' : '▸'}</span>
</span>
</span>
<span className={styles.actionCardHeader}>
<span className={[styles.methodBadge, methodClass(action.method)].join(' ')}>{action.method}</span>
<span className={styles.actionName}>{action.name}</span>
<span className={styles.actionId}>{action.id}</span>
</span>
<span className={styles.actionUrl}>{action.url}</span>
<span className={styles.actionMeta}>
<span className={styles.authBadge}>{AUTH_LABELS[action.authenticationType] ?? action.authenticationType}</span>
</span>
</button>
{expanded && <div className={styles.cardDetails}>
{action.description && <span className={styles.actionDescription}>{action.description}</span>}
{/* ── Template references (Step 16.5) ── */}
{hasTemplates && (
<div className={styles.templateSection}>
<div className={styles.templateSectionTitle}>Template References</div>
{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 (
<div key={i} className={styles.templateRow}>
<code className={styles.templateRaw}>{token.raw}</code>
<span className={styles.templateLocation}>{token.location}</span>
{unsupportedProp ? (
<span className={styles.templateUnresolved}>
unsupported property &ldquo;{token.propertyName}&rdquo;
</span>
) : resolved ? (
<span className={styles.templateResolved}>
{comp!.name} / {comp!.type}
</span>
) : (
<span className={styles.templateUnresolved}>
component &ldquo;{token.componentName}&rdquo; not found
</span>
)}
</div>
);
})}
{malformed.map((bad, i) => (
<div key={`bad-${i}`} className={styles.templateRow}>
<code className={styles.templateRaw}>{bad.raw}</code>
<span className={styles.templateLocation}>{bad.location}</span>
<span className={styles.templateUnresolved}>malformed &#x2014; missing &#x7B;&#x7B;&#x7D;&#x7D; closing braces</span>
</div>
))}
</div>
)}
{/* ── Diagnostics ── */}
<DiagList diags={diags} />
<div className={styles.actionControls}>
<button
type="button"
className={styles.smallButton}
onClick={onEdit}
disabled={controlsLocked}
aria-label={`Edit REST action ${action.name}`}
>
{isEditing ? 'Editing' : 'Edit'}
</button>
<button
type="button"
className={styles.smallButton}
onClick={onDuplicate}
disabled={controlsLocked}
aria-label={`Duplicate REST action ${action.name}`}
>
Duplicate
</button>
<button
type="button"
className={styles.dangerButton}
onClick={onDelete}
disabled={controlsLocked}
aria-label={`Delete REST action ${action.name}`}
>
Delete
</button>
</div>
{/* ── Test Action ── */}
<div className={styles.testRow}>
<button
className={styles.testBtn}
disabled={test.status === 'running' || actionsLocked}
onClick={handleTest}
>
{test.status === 'running' ? 'Running…' : 'Test Action'}
</button>
{test.status === 'running' && (
<span className={styles.testStatus}>Calling proxy</span>
)}
{test.status === 'ok' && (
<div className={styles.testResult}>
<div className={styles.testStatus}>
HTTP {test.response.status} {test.response.statusText}
{' · '}{test.response.durationMs} ms
{' · '}{test.response.ok ? '✓ ok' : '✗ non-2xx'}
</div>
<pre className={styles.testResultPre}>
{JSON.stringify(test.response.body, null, 2)}
</pre>
</div>
)}
{test.status === 'error' && (
<div className={styles.testResult}>
<pre className={[styles.testResultPre, styles.testResultError].join(' ')}>
{test.message}
</pre>
</div>
)}
</div>
</div>}
</div>
);
}
// ── 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 (
<div className={styles.bindingCard}>
<button type="button" className={styles.cardToggle} onClick={onToggle} aria-expanded={expanded} aria-label={`${expanded ? 'Collapse' : 'Expand'} binding ${binding.id}`}>
<span className={styles.toggleHeadingRow}>
<span className={styles.recordType}>{sourceActionId ? 'Response binding' : 'Component binding'}</span>
<span className={styles.toggleStatus}>
{diags.length > 0 && <span className={styles.issueBadge}>{diags.length} issue{diags.length === 1 ? '' : 's'}</span>}
<span className={styles.chevron} aria-hidden="true">{expanded ? '▾' : '▸'}</span>
</span>
</span>
<span className={styles.bindingCardHeader}><span className={styles.bindingId}>{binding.id}</span><span className={styles.triggerBadge}>{trigger}</span></span>
<span className={styles.bindingCompactFlow}>{binding.source} <span aria-hidden="true"></span> {binding.target}</span>
</button>
{expanded && <div className={styles.cardDetails}>
{/* ── Source / Target flow ── */}
<div className={styles.bindingFlow}>
{/* Source */}
<div className={styles.bindingRow}>
<span className={styles.bindingLabel}>Source</span>
<span className={styles.bindingExpr}>{binding.source}</span>
{sourceAction && (
<span className={styles.bindingResolved}>
{sourceAction.method} {sourceAction.name}
</span>
)}
{sourceComponent && (
<span className={styles.bindingResolved}>
{sourceComponent.name} / {sourceComponent.type}
</span>
)}
{!sourceAction && !sourceComponent && sourceActionId && (
<span className={styles.bindingUnresolved}>
action &ldquo;{sourceActionId}&rdquo; not found
</span>
)}
{!sourceAction && !sourceComponent && sourceComponentName && !sourceActionId && (
<span className={styles.bindingUnresolved}>
component &ldquo;{sourceComponentName}&rdquo; not found
</span>
)}
</div>
{/* Target */}
<div className={styles.bindingRow}>
<span className={styles.bindingLabel}>Target</span>
<span className={styles.bindingExpr}>{binding.target}</span>
{targetComponent ? (
<span className={styles.bindingResolved}>
{targetComponent.name} / {targetComponent.type}
</span>
) : targetComponentName ? (
<span className={styles.bindingUnresolved}>
component &ldquo;{targetComponentName}&rdquo; not found
</span>
) : targetVariableName ? (
<span className={styles.bindingResolved}>
Variable {targetVariableName}
</span>
) : null}
</div>
</div>
{/* ── Diagnostics ── */}
<DiagList diags={diags} />
<div className={styles.actionControls}>
<button type="button" className={styles.smallButton} onClick={onEdit} aria-label={`Edit binding ${binding.id}`} disabled={!sourceActionId} title={!sourceActionId ? 'Edit component bindings in the JSON Editor.' : undefined}>Edit</button>
<button type="button" className={styles.dangerButton} onClick={onDelete} aria-label={`Delete binding ${binding.id}`}>Delete</button>
</div>
</div>}
</div>
);
}
// ── 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<number | null>(null);
const [editingBindingIndex, setEditingBindingIndex] = useState<number | null>(null);
const [editingVariableName, setEditingVariableName] = useState<string | null>(null);
const [addingVariable, setAddingVariable] = useState(false);
const [secrets, setSecrets] = useState<SecretMetadata[]>([]);
const [openSections, setOpenSections] = useState(() => new Set(['actions']));
const [openActions, setOpenActions] = useState<Set<string>>(() => new Set());
const [openBindings, setOpenBindings] = useState<Set<string>>(() => new Set());
const [openVariables, setOpenVariables] = useState<Set<string>>(() => new Set());
const toggleOpen = useCallback((setter: React.Dispatch<React.SetStateAction<Set<string>>>, 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 (
<div className={styles.page}>
<ValidationSummary />
{/* ── Page header ── */}
<div className={styles.pageHeader}>
<div className={styles.pageTitle}>Actions &amp; Bindings</div>
<div className={styles.pageSubtitle}>
Create and configure anonymous REST actions here without editing
project JSON by hand. Valid edits synchronize with the canonical
document immediately. <strong>Test Action</strong> calls the backend
proxy with the configured action; diagnostics highlight invalid
definitions and broken references.
</div>
</div>
<SecretManager secrets={secrets} actions={actions} onChanged={refreshSecrets} />
{/* ══ Actions section ══════════════════════════════════════════ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'actions')} aria-expanded={openSections.has('actions')} aria-label={`${openSections.has('actions') ? 'Collapse' : 'Expand'} REST Actions section`}>
<span className={styles.chevron} aria-hidden="true">{openSections.has('actions') ? '▾' : '▸'}</span>
<span><span className={styles.sectionTitle}>REST Actions</span><span className={styles.sectionDescription}>Requests sent through the backend proxy.</span></span>
</button>
<div className={styles.sectionHeaderMeta}>
<span className={styles.sectionCount}>{actions.length}</span>
<button
type="button"
className={styles.primaryButton}
data-testid="add-rest-action"
onClick={() => { setOpenSections((current) => new Set(current).add('actions')); handleAddAction(); }}
disabled={editingActionIndex !== null}
title={editingActionIndex !== null
? 'Finish the current action edit before adding another.'
: undefined}
>
+ Add REST action
</button>
</div>
</div>
<div className={styles.sectionBody} hidden={!openSections.has('actions')}>
{/* Summary banner */}
{actions.length > 0 && (actionWarnings + actionInfos) === 0 && (
<div className={styles.diagSummaryOk}>
No issues found
</div>
)}
{actions.length > 0 && (actionWarnings + actionInfos) > 0 && (
<div className={styles.diagSummary}>
{actionWarnings + actionInfos} issue{actionWarnings + actionInfos !== 1 ? 's' : ''} detected
</div>
)}
{actions.length === 0 ? (
<div className={styles.empty}>
No REST actions defined. Add one to begin configuring the canonical
project document visually.
</div>
) : (
<div className={styles.recordList}>
{actions.map((action, actionIndex) => (
<div className={styles.recordGroup} key={`${action.id}-${actionIndex}`}>
<ActionCard
action={action}
diags={diagMap[actionDiagnosticKey(actionIndex)] ?? []}
allComponents={allComponents}
isEditing={editingActionIndex === actionIndex}
actionsLocked={editingActionIndex !== null}
expanded={openActions.has(`${action.id}-${actionIndex}`)}
onToggle={() => toggleOpen(setOpenActions, `${action.id}-${actionIndex}`)}
onEdit={() => { setEditingActionIndex(actionIndex); setOpenActions((current) => new Set(current).add(`${action.id}-${actionIndex}`)); }}
onDuplicate={() => handleDuplicateAction(action)}
onDelete={() => handleDeleteAction(actionIndex, action)}
/>
{editingActionIndex === actionIndex && (
<RestActionEditor
key={`${action.id}-${actionIndex}`}
action={action}
actions={actions}
components={allComponents}
variables={variables}
secrets={secrets}
onChange={(updatedAction) =>
handleUpdateAction(actionIndex, updatedAction)
}
onDone={() => setEditingActionIndex(null)}
/>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* ══ Bindings section ═════════════════════════════════════════ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'bindings')} aria-expanded={openSections.has('bindings')} aria-label={`${openSections.has('bindings') ? 'Collapse' : 'Expand'} Response Bindings section`}>
<span className={styles.chevron} aria-hidden="true">{openSections.has('bindings') ? '▾' : '▸'}</span>
<span><span className={styles.sectionTitle}>Response Bindings</span><span className={styles.sectionDescription}>Routes successful action responses into components or runtime variables.</span></span>
</button>
<div className={styles.sectionHeaderMeta}>
<span className={styles.sectionCount}>{bindings.length}</span>
<button type="button" className={styles.primaryButton} data-testid="add-response-binding" onClick={() => { setOpenSections((current) => new Set(current).add('bindings')); handleAddBinding(); }} disabled={actions.length === 0 || targetOptions.length === 0 || editingBindingIndex !== null} title={actions.length === 0 ? 'Add a REST action first.' : targetOptions.length === 0 ? 'Add a supported target component or variable first.' : undefined}>+ Add response binding</button>
</div>
</div>
<div className={styles.sectionBody} hidden={!openSections.has('bindings')}>
{/* Summary banner */}
{bindings.length > 0 && bindingWarnings === 0 && (
<div className={styles.diagSummaryOk}>
No issues found
</div>
)}
{bindings.length > 0 && bindingWarnings > 0 && (
<div className={styles.diagSummary}>
{bindingWarnings} issue{bindingWarnings !== 1 ? 's' : ''} detected
</div>
)}
{bindings.length === 0 ? (
<div className={styles.empty}>
No response bindings defined. Add a REST action and a supported
component or variable target to configure one visually.
</div>
) : (
<div className={styles.recordList}>
{bindings.map((binding, bindingIndex) => (
<div className={styles.recordGroup} key={`${binding.id}-${bindingIndex}`}>
<BindingCard binding={binding} allComponents={allComponents} allActions={actions} diags={diagMap[binding.id] ?? []} expanded={openBindings.has(`${binding.id}-${bindingIndex}`)} onToggle={() => toggleOpen(setOpenBindings, `${binding.id}-${bindingIndex}`)} onEdit={() => { setEditingBindingIndex(bindingIndex); setOpenBindings((current) => new Set(current).add(`${binding.id}-${bindingIndex}`)); }} onDelete={() => {
if (window.confirm(`Delete binding "${binding.id}"?`)) {
setDoc((current) => removeBindingAt(current, bindingIndex));
setEditingBindingIndex((current) => {
if (current === null || current === bindingIndex) return null;
return current > bindingIndex ? current - 1 : current;
});
}
}} />
{editingBindingIndex === bindingIndex && <ResponseBindingEditor binding={binding} actions={actions} components={allComponents} variables={variables} onChange={(updated) => setDoc((current) => replaceBindingAt(current, bindingIndex, updated))} onDone={() => setEditingBindingIndex(null)} />}
</div>
))}
</div>
)}
</div>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<button type="button" className={styles.sectionToggle} onClick={() => toggleOpen(setOpenSections, 'variables')} aria-expanded={openSections.has('variables')} aria-label={`${openSections.has('variables') ? 'Collapse' : 'Expand'} Variables section`}>
<span className={styles.chevron} aria-hidden="true">{openSections.has('variables') ? '▾' : '▸'}</span>
<span><span className={styles.sectionTitle}>Variables</span><span className={styles.sectionDescription}>Typed defaults copied into ephemeral Preview runtime state.</span></span>
</button>
<div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{Object.keys(variables).length}</span><button type="button" className={styles.primaryButton} data-testid="add-variable" disabled={addingVariable || editingVariableName !== null} onClick={() => { setOpenSections((current) => new Set(current).add('variables')); setAddingVariable(true); }}>+ Add variable</button></div>
</div>
<div className={styles.sectionBody} hidden={!openSections.has('variables')}>
{addingVariable && <VariableEditor name="" variable={{ type: 'string' }} variables={variables} isNew onSave={(name, variable) => { setDoc((current) => setVariable(current, name, variable)); setAddingVariable(false); }} onCancel={() => setAddingVariable(false)} />}
{Object.keys(variables).length === 0 && !addingVariable ? <div className={styles.empty}>No variables declared.</div> : <div className={styles.recordList}>{Object.entries(variables).map(([name, variable]) => (
<div className={styles.recordGroup} key={name}>
<div className={styles.variableCard}>
<button type="button" className={styles.cardToggle} aria-expanded={openVariables.has(name)} aria-label={`${openVariables.has(name) ? 'Collapse' : 'Expand'} variable ${name}`} onClick={() => toggleOpen(setOpenVariables, name)}>
<span className={styles.toggleHeadingRow}><span className={styles.recordType}>Runtime variable</span><span className={styles.chevron} aria-hidden="true">{openVariables.has(name) ? '▾' : '▸'}</span></span>
<span className={styles.bindingCardHeader}><span className={styles.variableName}>{name}</span> <span className={styles.triggerBadge}>{variable.type}</span></span>
</button>
{openVariables.has(name) && <div className={styles.cardDetails}>
<div className={styles.variableDefault}><span className={styles.bindingLabel}>Default</span><code className={styles.bindingExpr}>{variable.defaultValue === undefined ? 'No default value' : JSON.stringify(variable.defaultValue)}</code></div>
{variable.description && <span className={styles.actionDescription}>{variable.description}</span>}
<div className={styles.actionControls}><button type="button" className={styles.smallButton} aria-label={`Edit variable ${name}`} onClick={() => { setEditingVariableName(name); setOpenVariables((current) => new Set(current).add(name)); }}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div>
</div>}
</div>
{editingVariableName === name && <VariableEditor name={name} variable={variable} variables={variables} onSave={(nextName, nextVariable) => { setDoc((current) => setVariable(current, nextName, nextVariable, name)); setEditingVariableName(null); }} onCancel={() => setEditingVariableName(null)} />}
</div>
))}</div>}
</div>
</div>
{/* ══ Overall summary (only shown when there are issues) ═══════ */}
{totalIssues === 0 && (actions.length > 0 || bindings.length > 0) && (
<div style={{ fontSize: 12, color: '#15803d' }}>
All actions and bindings are consistent.
</div>
)}
</div>
);
}
export default ActionInspector;