1240 lines
51 KiB
TypeScript
1240 lines
51 KiB
TypeScript
/**
|
||
* 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 “{token.propertyName}”
|
||
</span>
|
||
) : resolved ? (
|
||
<span className={styles.templateResolved}>
|
||
{comp!.name} / {comp!.type}
|
||
</span>
|
||
) : (
|
||
<span className={styles.templateUnresolved}>
|
||
component “{token.componentName}” 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 — missing {{}} 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 “{sourceActionId}” not found
|
||
</span>
|
||
)}
|
||
{!sourceAction && !sourceComponent && sourceComponentName && !sourceActionId && (
|
||
<span className={styles.bindingUnresolved}>
|
||
component “{sourceComponentName}” 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 “{targetComponentName}” 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 & 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;
|