Improve editor progressive disclosure
This commit is contained in:
parent
2cc6e7cfd8
commit
933201dc50
3
CODEX.md
3
CODEX.md
@ -326,3 +326,6 @@ Date: 2026-07-20
|
||||
- Standard Compose is restored healthy with empty history/secrets/configuration after automated cleanup. No commit or push occurred.
|
||||
- On 2026-08-07, the user reported all six manual workflows pass. Stored-history and backend-log marker scans passed, the expected redaction and `SECRET_NOT_FOUND` evidence was present, and cleanup restored healthy standard Compose with empty executions, secrets, secret-key configuration, and internal-origin configuration.
|
||||
- The Actions & Bindings information-density feedback is tracked in `SLICE6a.md` as a separate UX follow-up. The user granted explicit final Slice 6 sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6 sign-off.” Slice 6 is complete.
|
||||
- Slice 6a implementation and automation are complete through the manual gate: accessible collapsible sections/cards, compact record summaries, grouped REST editing, explicit secret creation, visible collapsed issue counts, and mounted-state preservation are implemented. Focused coverage passes at 18 tests; full frontend coverage passes at 25 suites / 520 tests; TypeScript and production build pass. `Slice 6a Density Manual` (`#22`) is prepared for `SLICE6a_MANUAL_TEST.md`.
|
||||
- The user reported all Slice 6a manual UX tests pass and that the changes look good. Disposable project `#22` was deleted; backend health, frontend HTTP 200, empty secrets, and empty history pass.
|
||||
- The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete.
|
||||
|
||||
@ -23,7 +23,7 @@ The slice files remain the detailed implementation plans. When an older list con
|
||||
| [Slice 4](SLICE4.md) | Proxy security and observability | Complete | None |
|
||||
| [Slice 5](SLICE5.md) | Validation and error handling | Complete | None |
|
||||
| [Slice 6](SLICE6.md) | Testing and release validation | Complete | None |
|
||||
| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Planned | Collapsible sections/cards and a less overwhelming REST-action editing experience |
|
||||
| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Complete | None |
|
||||
| [Slice 7](SLICE7.md) | MVP scope decision | Complete | Cross-document reconciliation is carried by Slices 6 and 8 |
|
||||
| [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain |
|
||||
|
||||
|
||||
10
TESTING.md
10
TESTING.md
@ -73,6 +73,16 @@ Automation and human acceptance remain distinct. `SLICE6_MANUAL_TEST.md`, cleanu
|
||||
|
||||
The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete.
|
||||
|
||||
## Slice 6a Progressive Disclosure Gate — 2026-08-07
|
||||
|
||||
Actions & Bindings now uses accessible collapsible major sections and independently collapsible action, binding, variable, and secret cards. REST editing is grouped into Identity, Endpoint & authentication, Request parameters, and Request body; the denser request groups start closed. Secret creation is explicit rather than exposing its full form whenever the section opens. Section hiding preserves mounted local editor state, and collapsed issue badges lead to expanded diagnostics.
|
||||
|
||||
Focused ActionInspector/SecretManager coverage passes at 18 tests. Full frontend coverage passes at 25 suites / 520 tests, standalone TypeScript passes, and the production build succeeds. Standard Compose was rebuilt successfully. A disposable high-density project, `Slice 6a Density Manual` (`#22`), is prepared for the proportional procedure in `SLICE6a_MANUAL_TEST.md`. Manual acceptance, cleanup, and explicit sign-off remain pending.
|
||||
|
||||
The user subsequently reported all five manual UX tests pass and that the changes look good. Cleanup deleted project `#22`, and final standard-stack checks returned backend health `ok`, frontend HTTP 200, empty secrets, and empty execution history.
|
||||
|
||||
The user granted explicit final Slice 6a sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6a sign-off.” Slice 6a is complete.
|
||||
|
||||
## Slice 4 Manual Gate
|
||||
|
||||
The five workflows in `SLICE4.md` passed on 2026-08-02 after focused remediation and repetition. Initial testing passed approved destinations, controls/recovery, and execution history; mixed-answer/rebinding DNS checks were initially unavailable, and private destination URLs in backend failure logs failed redaction. The logger was reduced to safe structured metadata. A disposable authoritative DNS fixture then proved mixed public/loopback answers fail closed and rebinding cannot bypass pinned validated addresses. The focused logging retest found zero private literals or raw transport errors. IPv6 literal classification was also corrected and retested. All disposable secrets, history, fixtures, networks, keys, and exceptions were removed. This is manual evidence separate from automation.
|
||||
|
||||
@ -47,6 +47,48 @@
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.sectionToggle,
|
||||
.cardToggle {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sectionToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.sectionToggle > span:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sectionToggle:focus-visible,
|
||||
.cardToggle:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: #57606a;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@ -89,6 +131,48 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardToggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggleHeadingRow,
|
||||
.toggleStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggleHeadingRow {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toggleStatus {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issueBadge {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 999px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cardDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.recordList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -263,6 +347,17 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bindingCompactFlow {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #57606a;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bindingCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -200,9 +200,59 @@ describe('Actions & Bindings visual REST action authoring', () => {
|
||||
'Routes successful action responses into components or runtime variables.',
|
||||
);
|
||||
expect(container.textContent).toContain('REST action');
|
||||
click(container.querySelector('button[aria-label="Expand Response Bindings section"]'));
|
||||
expect(container.textContent).toContain('Response binding');
|
||||
});
|
||||
|
||||
test('uses keyboard-accessible collapsed summaries and preserves local edits across section toggles', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
||||
});
|
||||
|
||||
const actionsSection = container.querySelector<HTMLButtonElement>('button[aria-label="Collapse REST Actions section"]');
|
||||
const actionToggle = container.querySelector<HTMLButtonElement>('button[aria-label="Expand REST action Lookup"]');
|
||||
const bindingsSection = container.querySelector<HTMLButtonElement>('button[aria-label="Expand Response Bindings section"]');
|
||||
expect(actionsSection?.tagName).toBe('BUTTON');
|
||||
expect(actionsSection?.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(actionToggle?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(bindingsSection?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(container.querySelector('button[aria-label="Edit REST action Lookup"]')).toBeNull();
|
||||
|
||||
actionToggle?.focus();
|
||||
expect(document.activeElement).toBe(actionToggle);
|
||||
click(actionToggle);
|
||||
click(container.querySelector('button[aria-label="Edit REST action Lookup"]'));
|
||||
|
||||
const groups = Array.from(container.querySelectorAll<HTMLDetailsElement>('[data-testid="rest-action-editor-action_lookup"] details'));
|
||||
expect(groups.map((group) => [group.querySelector('summary')?.textContent, group.open])).toEqual([
|
||||
['Identity', true],
|
||||
['Endpoint & authentication', true],
|
||||
['Request parameters', false],
|
||||
['Request body', false],
|
||||
]);
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>('[data-testid="action-name"]');
|
||||
setControlValue(nameInput, '');
|
||||
expect(nameInput?.value).toBe('');
|
||||
expect(currentDocument(container).project.actions[0].name).toBe('Lookup');
|
||||
|
||||
click(container.querySelector('button[aria-label="Collapse REST Actions section"]'));
|
||||
click(container.querySelector('button[aria-label="Expand REST Actions section"]'));
|
||||
expect(container.querySelector<HTMLInputElement>('[data-testid="action-name"]')?.value).toBe('');
|
||||
expect(currentDocument(container).project.actions[0].name).toBe('Lookup');
|
||||
});
|
||||
|
||||
test('keeps issue status visible on a collapsed card and reveals the diagnostic on expansion', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectProvider><DiagnosticSeed trigger="none" /><ActionInspector /></ProjectProvider>);
|
||||
});
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Expand REST action Lookup"]');
|
||||
expect(toggle?.textContent).toContain('1 issue');
|
||||
expect(container.textContent).not.toContain('Action "Lookup" is not triggered by any component or page event.');
|
||||
click(toggle);
|
||||
expect(container.textContent).toContain('Action "Lookup" is not triggered by any component or page event.');
|
||||
});
|
||||
|
||||
test('suggests canonical request references and common header names while preserving custom headers', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
@ -213,6 +263,7 @@ describe('Actions & Bindings visual REST action authoring', () => {
|
||||
</ProjectProvider>,
|
||||
);
|
||||
});
|
||||
click(container.querySelector('button[aria-label="Expand REST action Lookup"]'));
|
||||
click(container.querySelector('button[aria-label="Edit REST action Lookup"]'));
|
||||
|
||||
setControlValue(
|
||||
@ -371,6 +422,7 @@ describe('Actions & Bindings visual REST action authoring', () => {
|
||||
click(container.querySelector('[data-testid="add-response-binding"]'));
|
||||
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Binding target"]'), 'variables.capturedId');
|
||||
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
click(container.querySelector('button[aria-label="Expand variable capturedId"]'));
|
||||
click(container.querySelector('button[aria-label="Delete variable capturedId"]'));
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('binding "binding_response_1"'));
|
||||
expect(currentDocument(container).project.variables.capturedId).toBeDefined();
|
||||
@ -394,6 +446,8 @@ describe('Actions & Bindings visual REST action authoring', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectProvider><ProjectSeed legacyBinding /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
||||
});
|
||||
click(container.querySelector('button[aria-label="Expand Response Bindings section"]'));
|
||||
click(container.querySelector('button[aria-label="Expand binding binding_legacy"]'));
|
||||
click(container.querySelector('button[aria-label="Edit binding binding_legacy"]'));
|
||||
const trigger = container.querySelector<HTMLSelectElement>('select[aria-label="Binding trigger"]');
|
||||
expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess', 'onClick']);
|
||||
@ -421,6 +475,10 @@ describe('Actions & Bindings visual REST action authoring', () => {
|
||||
root.render(<ProjectProvider><DiagnosticSeed trigger="none" /><ActionInspector /></ProjectProvider>);
|
||||
});
|
||||
|
||||
click(container.querySelector('button[aria-label="Expand REST action Lookup"]'));
|
||||
click(container.querySelector('button[aria-label="Expand Response Bindings section"]'));
|
||||
click(container.querySelector('button[aria-label="Expand binding binding_lookup"]'));
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'Action "Lookup" is not triggered by any component or page event.',
|
||||
);
|
||||
|
||||
@ -594,6 +594,8 @@ type ActionCardProps = {
|
||||
allComponents: CanvasComponent[];
|
||||
isEditing: boolean;
|
||||
actionsLocked: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
@ -605,6 +607,8 @@ function ActionCard({
|
||||
allComponents,
|
||||
isEditing,
|
||||
actionsLocked,
|
||||
expanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
@ -651,28 +655,27 @@ function ActionCard({
|
||||
|
||||
return (
|
||||
<div className={styles.actionCard}>
|
||||
<div className={styles.recordType}>REST action</div>
|
||||
{/* ── Header: method + name + id ── */}
|
||||
<div className={styles.actionCardHeader}>
|
||||
<span className={[styles.methodBadge, methodClass(action.method)].join(' ')}>
|
||||
{action.method}
|
||||
<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.actionName}>{action.name}</span>
|
||||
<span className={styles.actionId}>{action.id}</span>
|
||||
</div>
|
||||
|
||||
{/* ── URL ── */}
|
||||
<div className={styles.actionUrl}>{action.url}</div>
|
||||
|
||||
{/* ── Auth + description ── */}
|
||||
<div className={styles.actionMeta}>
|
||||
<span className={styles.authBadge}>
|
||||
{AUTH_LABELS[action.authenticationType] ?? action.authenticationType}
|
||||
<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>
|
||||
{action.description && (
|
||||
<span className={styles.actionDescription}>{action.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<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 && (
|
||||
@ -780,6 +783,7 @@ function ActionCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -791,6 +795,8 @@ type BindingCardProps = {
|
||||
allComponents: CanvasComponent[];
|
||||
allActions: RestAction[];
|
||||
diags: Diagnostic[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
@ -800,6 +806,8 @@ function BindingCard({
|
||||
allComponents,
|
||||
allActions,
|
||||
diags,
|
||||
expanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: BindingCardProps): React.ReactElement {
|
||||
@ -822,12 +830,19 @@ function BindingCard({
|
||||
|
||||
return (
|
||||
<div className={styles.bindingCard}>
|
||||
<div className={styles.recordType}>{sourceActionId ? 'Response binding' : 'Component binding'}</div>
|
||||
{/* ── Header: id + trigger ── */}
|
||||
<div className={styles.bindingCardHeader}>
|
||||
<span className={styles.bindingId}>{binding.id}</span>
|
||||
<span className={styles.triggerBadge}>{trigger}</span>
|
||||
</div>
|
||||
<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}>
|
||||
@ -883,6 +898,7 @@ function BindingCard({
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -903,6 +919,18 @@ function ActionInspector(): React.ReactElement {
|
||||
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([]); }
|
||||
@ -932,6 +960,7 @@ function ActionInspector(): React.ReactElement {
|
||||
);
|
||||
setDoc((current) => appendRestAction(current, action));
|
||||
setEditingActionIndex(actions.length);
|
||||
setOpenActions((current) => new Set(current).add(`${action.id}-${actions.length}`));
|
||||
}, [actions, doc, setDoc]);
|
||||
|
||||
const handleUpdateAction = useCallback(
|
||||
@ -950,6 +979,7 @@ function ActionInspector(): React.ReactElement {
|
||||
);
|
||||
setDoc((current) => appendRestAction(current, duplicate));
|
||||
setEditingActionIndex(actions.length);
|
||||
setOpenActions((current) => new Set(current).add(`${duplicate.id}-${actions.length}`));
|
||||
},
|
||||
[actions, doc, setDoc],
|
||||
);
|
||||
@ -978,6 +1008,7 @@ function ActionInspector(): React.ReactElement {
|
||||
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) => {
|
||||
@ -1036,17 +1067,17 @@ function ActionInspector(): React.ReactElement {
|
||||
{/* ══ Actions section ══════════════════════════════════════════ */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div>
|
||||
<div className={styles.sectionTitle}>REST Actions</div>
|
||||
<div className={styles.sectionDescription}>Requests sent through the backend proxy.</div>
|
||||
</div>
|
||||
<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={handleAddAction}
|
||||
onClick={() => { setOpenSections((current) => new Set(current).add('actions')); handleAddAction(); }}
|
||||
disabled={editingActionIndex !== null}
|
||||
title={editingActionIndex !== null
|
||||
? 'Finish the current action edit before adding another.'
|
||||
@ -1057,6 +1088,8 @@ function ActionInspector(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionBody} hidden={!openSections.has('actions')}>
|
||||
|
||||
{/* Summary banner */}
|
||||
{actions.length > 0 && (actionWarnings + actionInfos) === 0 && (
|
||||
<div className={styles.diagSummaryOk}>
|
||||
@ -1084,7 +1117,9 @@ function ActionInspector(): React.ReactElement {
|
||||
allComponents={allComponents}
|
||||
isEditing={editingActionIndex === actionIndex}
|
||||
actionsLocked={editingActionIndex !== null}
|
||||
onEdit={() => setEditingActionIndex(actionIndex)}
|
||||
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)}
|
||||
/>
|
||||
@ -1106,21 +1141,24 @@ function ActionInspector(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══ Bindings section ═════════════════════════════════════════ */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div>
|
||||
<div className={styles.sectionTitle}>Response Bindings</div>
|
||||
<div className={styles.sectionDescription}>Routes successful action responses into components or runtime variables.</div>
|
||||
</div>
|
||||
<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={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>
|
||||
<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}>
|
||||
@ -1142,7 +1180,7 @@ function ActionInspector(): React.ReactElement {
|
||||
<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] ?? []} onEdit={() => setEditingBindingIndex(bindingIndex)} onDelete={() => {
|
||||
<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) => {
|
||||
@ -1156,29 +1194,36 @@ function ActionInspector(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div>
|
||||
<div className={styles.sectionTitle}>Variables</div>
|
||||
<div className={styles.sectionDescription}>Typed defaults copied into ephemeral Preview runtime state.</div>
|
||||
</div>
|
||||
<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={() => setAddingVariable(true)}>+ Add variable</button></div>
|
||||
<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}>
|
||||
<div className={styles.recordType}>Runtime variable</div>
|
||||
<div className={styles.bindingCardHeader}><span className={styles.variableName}>{name}</span> <span className={styles.triggerBadge}>{variable.type}</span></div>
|
||||
<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)}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div>
|
||||
<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) ═══════ */}
|
||||
|
||||
@ -343,6 +343,9 @@ function RestActionEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details className={styles.actionEditorDetails} open>
|
||||
<summary>Identity</summary>
|
||||
<div className={styles.detailsBody}>
|
||||
<div className={styles.formGrid}>
|
||||
<label className={styles.formField}>
|
||||
<span className={styles.formLabel}>Action ID</span>
|
||||
@ -383,7 +386,12 @@ function RestActionEditor({
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className={styles.actionEditorDetails} open>
|
||||
<summary>Endpoint & authentication</summary>
|
||||
<div className={styles.detailsBody}>
|
||||
<div className={styles.endpointRow}>
|
||||
<label className={styles.methodField}>
|
||||
<span className={styles.formLabel}>HTTP method</span>
|
||||
@ -475,8 +483,10 @@ function RestActionEditor({
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className={styles.actionEditorDetails} open>
|
||||
<details className={styles.actionEditorDetails}>
|
||||
<summary>Request parameters</summary>
|
||||
<div className={styles.detailsBody}>
|
||||
<RequestInputEditor
|
||||
@ -526,7 +536,7 @@ function RestActionEditor({
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className={styles.actionEditorDetails} open>
|
||||
<details className={styles.actionEditorDetails}>
|
||||
<summary>Request body</summary>
|
||||
<div className={styles.detailsBody}>
|
||||
<div className={styles.formField}>
|
||||
|
||||
@ -18,14 +18,27 @@ describe('SecretManager', () => {
|
||||
beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); });
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); jest.resetAllMocks(); });
|
||||
|
||||
test('starts with compact section and card summaries that expose expansion state', async () => {
|
||||
await act(async () => { root.render(<SecretManager secrets={[metadata]} actions={[]} onChanged={jest.fn()} />); });
|
||||
const section = container.querySelector<HTMLButtonElement>('button[aria-label="Expand Secrets section"]');
|
||||
expect(section?.getAttribute('aria-expanded')).toBe('false');
|
||||
await act(async () => { section!.click(); });
|
||||
const card = container.querySelector<HTMLButtonElement>('button[aria-label="Expand secret Production token"]');
|
||||
expect(card?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(container.textContent).not.toContain('Replace value');
|
||||
await act(async () => { card!.click(); });
|
||||
expect(container.textContent).toContain('Replace value');
|
||||
});
|
||||
|
||||
test('submits a masked credential and refreshes metadata without displaying its value', async () => {
|
||||
mockedApi.createSecret.mockResolvedValue(metadata);
|
||||
const changed = jest.fn().mockResolvedValue(undefined);
|
||||
await act(async () => { root.render(<SecretManager secrets={[]} actions={[]} onChanged={changed} />); });
|
||||
await act(async () => { Array.from(container.querySelectorAll('button')).find((button) => button.textContent === '+ Add secret')!.click(); });
|
||||
const inputs = container.querySelectorAll<HTMLInputElement>('input');
|
||||
setInput(inputs[0], 'Production token'); setInput(inputs[1], 'stored-token-value');
|
||||
expect(inputs[1].type).toBe('password');
|
||||
await act(async () => { container.querySelector<HTMLButtonElement>('button')!.click(); });
|
||||
await act(async () => { Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Add secret')!.click(); });
|
||||
expect(mockedApi.createSecret).toHaveBeenCalledWith('Production token', 'bearerToken', { token: 'stored-token-value' });
|
||||
expect(changed).toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain('stored-token-value');
|
||||
@ -33,6 +46,8 @@ describe('SecretManager', () => {
|
||||
|
||||
test('blocks deletion when an unsaved action references the secret', async () => {
|
||||
await act(async () => { root.render(<SecretManager secrets={[metadata]} actions={[{ id: 'a', name: 'Protected request', method: 'GET', url: 'https://example.test', authenticationType: 'bearerToken', secretReferenceId: metadata.id }]} onChanged={jest.fn()} />); });
|
||||
await act(async () => { container.querySelector<HTMLButtonElement>('button[aria-label="Expand Secrets section"]')!.click(); });
|
||||
await act(async () => { container.querySelector<HTMLButtonElement>('button[aria-label="Expand secret Production token"]')!.click(); });
|
||||
const deleteButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Delete')!;
|
||||
await act(async () => { deleteButton.click(); });
|
||||
expect(mockedApi.deleteSecret).not.toHaveBeenCalled();
|
||||
|
||||
@ -11,11 +11,14 @@ const types: Array<{ value: CredentialType; label: string }> = [
|
||||
];
|
||||
|
||||
export default function SecretManager({ secrets, actions, onChanged }: Props): React.ReactElement {
|
||||
const [sectionOpen, setSectionOpen] = useState(false);
|
||||
const [openSecrets, setOpenSecrets] = useState<Set<string>>(() => new Set());
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<SecretMetadata | null>(null);
|
||||
const [name, setName] = useState(''); const [type, setType] = useState<CredentialType>('bearerToken');
|
||||
const [first, setFirst] = useState(''); const [second, setSecond] = useState('');
|
||||
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
|
||||
const reset = () => { setEditing(null); setName(''); setFirst(''); setSecond(''); setError(''); };
|
||||
const reset = () => { setEditing(null); setAdding(false); setName(''); setFirst(''); setSecond(''); setError(''); };
|
||||
const value = (): SecretValue => type === 'basicAuth' ? { username: first, password: second } : type === 'bearerToken' ? { token: first } : { parameterName: first, value: second };
|
||||
const save = async () => {
|
||||
setBusy(true); setError('');
|
||||
@ -28,13 +31,17 @@ export default function SecretManager({ secrets, actions, onChanged }: Props): R
|
||||
if (!window.confirm(`Delete secret “${secret.name}”? This cannot be undone.`)) return;
|
||||
try { await api.deleteSecret(secret.id); await onChanged(); setError(''); } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||
};
|
||||
const toggleSecret = (id: string) => setOpenSecrets((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
||||
return <div className={styles.section}>
|
||||
<div className={styles.sectionHeader}><div><div className={styles.sectionTitle}>Secrets</div><div className={styles.sectionDescription}>Encrypted server-side credentials. Stored values are never displayed after submission.</div></div><span className={styles.sectionCount}>{secrets.length}</span></div>
|
||||
{secrets.map((secret) => <div className={styles.recordGroup} key={secret.id}><div className={styles.actionCard}><strong>{secret.name}</strong> <span className={styles.authBadge}>{types.find((item) => item.value === secret.authenticationType)?.label}</span><div className={styles.actionId}>{secret.id}</div><div className={styles.actionControls}><button className={styles.smallButton} onClick={() => { setEditing(secret); setName(secret.name); setType(secret.authenticationType); setFirst(''); setSecond(''); }}>Replace value</button><button className={styles.dangerButton} onClick={() => void remove(secret)}>Delete</button></div></div></div>)}
|
||||
<div className={styles.editorPanel}>
|
||||
<div className={styles.sectionHeader}><button type="button" className={styles.sectionToggle} aria-expanded={sectionOpen} aria-label={`${sectionOpen ? 'Collapse' : 'Expand'} Secrets section`} onClick={() => setSectionOpen((value) => !value)}><span className={styles.chevron} aria-hidden="true">{sectionOpen ? '▾' : '▸'}</span><span><span className={styles.sectionTitle}>Secrets</span><span className={styles.sectionDescription}>Encrypted server-side credentials. Stored values are never displayed after submission.</span></span></button><div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{secrets.length}</span><button type="button" className={styles.primaryButton} disabled={adding || editing !== null} onClick={() => { setSectionOpen(true); setAdding(true); setName(''); setType('bearerToken'); setFirst(''); setSecond(''); setError(''); }}>+ Add secret</button></div></div>
|
||||
<div className={styles.sectionBody} hidden={!sectionOpen}>
|
||||
<div className={styles.recordList}>{secrets.map((secret) => <div className={styles.recordGroup} key={secret.id}><div className={styles.actionCard}><button type="button" className={styles.cardToggle} aria-expanded={openSecrets.has(secret.id)} aria-label={`${openSecrets.has(secret.id) ? 'Collapse' : 'Expand'} secret ${secret.name}`} onClick={() => toggleSecret(secret.id)}><span className={styles.toggleHeadingRow}><span className={styles.recordType}>Stored secret</span><span className={styles.chevron} aria-hidden="true">{openSecrets.has(secret.id) ? '▾' : '▸'}</span></span><span className={styles.actionCardHeader}><strong className={styles.actionName}>{secret.name}</strong><span className={styles.authBadge}>{types.find((item) => item.value === secret.authenticationType)?.label}</span><span className={styles.actionId}>{secret.id}</span></span></button>{openSecrets.has(secret.id) && <div className={styles.cardDetails}><div className={styles.actionControls}><button className={styles.smallButton} onClick={() => { setAdding(false); setEditing(secret); setName(secret.name); setType(secret.authenticationType); setFirst(''); setSecond(''); setOpenSecrets((current) => new Set(current).add(secret.id)); }}>Replace value</button><button className={styles.dangerButton} onClick={() => void remove(secret)}>Delete</button></div></div>}</div></div>)}</div>
|
||||
{error && <div className={styles.inlineError} role="alert">{error}</div>}
|
||||
{(adding || editing) && <div className={styles.editorPanel}>
|
||||
<div className={styles.formGrid}><label className={styles.formField}><span className={styles.formLabel}>Secret name</span><input className={styles.formInput} value={name} onChange={(e) => setName(e.target.value)} /></label><label className={styles.formField}><span className={styles.formLabel}>Authentication type</span><select className={styles.formSelect} value={type} disabled={!!editing} onChange={(e) => setType(e.target.value as CredentialType)}>{types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label></div>
|
||||
<div className={styles.formGrid}><label className={styles.formField}><span className={styles.formLabel}>{type === 'basicAuth' ? 'Username' : type === 'bearerToken' ? 'Token' : type === 'apiKeyHeader' ? 'Header name' : 'Query parameter name'}</span><input className={styles.formInput} type={type === 'bearerToken' ? 'password' : 'text'} value={first} onChange={(e) => setFirst(e.target.value)} autoComplete="off" /></label>{type !== 'bearerToken' && <label className={styles.formField}><span className={styles.formLabel}>{type === 'basicAuth' ? 'Password' : 'API key'}</span><input className={styles.formInput} type="password" value={second} onChange={(e) => setSecond(e.target.value)} autoComplete="new-password" /></label>}</div>
|
||||
{error && <div className={styles.inlineError} role="alert">{error}</div>}<div className={styles.editorActions}><button className={styles.primaryButton} disabled={busy || !name.trim() || !first || (type !== 'bearerToken' && !second)} onClick={() => void save()}>{editing ? 'Replace secret' : 'Add secret'}</button>{editing && <button className={styles.smallButton} onClick={reset}>Cancel</button>}</div>
|
||||
<div className={styles.editorActions}><button className={styles.primaryButton} disabled={busy || !name.trim() || !first || (type !== 'bearerToken' && !second)} onClick={() => void save()}>{editing ? 'Replace secret' : 'Add secret'}</button><button className={styles.smallButton} onClick={reset}>Cancel</button></div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user