Add visual REST request input authoring

This commit is contained in:
Victor Wiebe 2026-07-19 08:58:08 -04:00
parent 3994eaa397
commit 6fd084c8b3
12 changed files with 300 additions and 5 deletions

View File

@ -187,3 +187,9 @@ Remaining release work includes broader backend tests, browser end-to-end tests,
## Slice 2 Increment 2 Button Event Validation ## Slice 2 Increment 2 Button Event Validation
On 2026-07-19, focused tests passed (3 suites / 6 tests), full frontend tests passed (12 suites / 462 tests), the frontend and backend builds passed, and the schema matrix passed at 13 / 2 / 2. `ComponentEvent.inputMap` was documented as compatibility-only; executed request inputs continue to use component/variable REST templates. Automated Preview dispatch passed. The user then manually verified creating a REST action and assigning it visually to a Button; Increment 2 Button `onClick` acceptance passed. On 2026-07-19, focused tests passed (3 suites / 6 tests), full frontend tests passed (12 suites / 462 tests), the frontend and backend builds passed, and the schema matrix passed at 13 / 2 / 2. `ComponentEvent.inputMap` was documented as compatibility-only; executed request inputs continue to use component/variable REST templates. Automated Preview dispatch passed. The user then manually verified creating a REST action and assigning it visually to a Button; Increment 2 Button `onClick` acceptance passed.
## Slice 2 Request Input Validation
On 2026-07-19, focused request-input tests passed at 3 suites / 9 tests, the full frontend suite passed at 14 suites / 467 tests, frontend and backend builds passed, and the schema matrix passed at 13 / 2 / 2.
The UI writes executed component/variable templates to URL, header, query, and body fields while excluding path parameters and `inputMap`. Standalone `tsc` reproduced the recorded dependency mismatch. Manual request-input acceptance remains pending.

View File

@ -201,3 +201,13 @@ Date: 2026-07-19
- Standalone frontend `tsc --noEmit` retains the recorded dependency failure. - Standalone frontend `tsc --noEmit` retains the recorded dependency failure.
- Automated browser validation was unavailable. The user manually accepted the Increment 2 Button `onClick` workflow on 2026-07-19 by creating a REST action and assigning it visually to a Button. - Automated browser validation was unavailable. The user manually accepted the Increment 2 Button `onClick` workflow on 2026-07-19 by creating a REST action and assigning it visually to a Button.
- No commit or push was made. - No commit or push was made.
### Request Input Addendum
- Added guided component/variable reference insertion to REST action URL, header, query, and body template fields.
- The UI writes only executed `{{components.<name>.value}}` and `{{variables.<name>}}` templates; path parameters and `ComponentEvent.inputMap` remain excluded.
- Header/query destinations replace the selected value; URL/body destinations append for further composition in the existing raw editor.
- Added pure canonical-update and UI interaction coverage.
- Validation passes: 14 frontend suites / 467 tests, frontend production build, backend build, and the 13 / 2 / 2 schema matrix.
- Standalone frontend `tsc --noEmit` retains the recorded dependency failure.
- Manual request-input acceptance remains pending.

32
NEXT_SESSION_PROMPT.md Normal file
View File

@ -0,0 +1,32 @@
# Next Session Prompt
Continue Conductor Slice 2: Visual Configuration from the latest `main` branch.
First read `CODEX.md`, `TASKS.md`, `MVP_SCOPE.md`, `SLICE2.md`, and `BASELINE.md`. Verify the current branch, working tree, recent commits, and relevant diffs before changing anything.
Completed and pushed work includes:
- Visual anonymous REST action creation, editing, duplication, and reference-aware deletion.
- Visual Button `onClick` action assignment with add/change/clear, no-action state, diagnostics, canonical synchronization, automated Preview execution coverage, and manual acceptance.
- Visual request-input authoring from existing component and variable values into executed REST URL, header, query, and body templates.
- `ComponentEvent.inputMap` is compatibility-only and must not be used by new UI.
Begin the next coherent vertical increment: visual response bindings and variable authoring.
Requirements:
- Create, edit, and delete canonical top-level `project.bindings` visually.
- Default new action-response bindings to `trigger: "onSuccess"`.
- Select an existing REST action response source and supported component or variable target.
- Preserve compatibility with legacy `onClick` response bindings without creating new ones.
- Do not use deprecated `action.responseMapping`.
- Add variable declaration/default-value editing with reference-aware diagnostics.
- Keep runtime values, loading state, responses, and errors ephemeral.
- Preserve anonymous-only REST configuration; authentication/secrets remain Slice 3.
- Leave page-load actions for the following Slice 2 increment unless a minimal supporting change is unavoidable.
- Add focused tests, update representative fixtures and durable state documents, and run the Node 20/npm 10 focused/full frontend tests, frontend build, backend build, schema matrix, and proportional manual workflow.
- Report the standalone TypeScript 4.9 / `@types/node` incompatibility separately from the passing CRA production compile.
Request-input manual acceptance is still pending. Do not mark it accepted without user confirmation.
Do not commit or push unless explicitly requested.

View File

@ -2,7 +2,7 @@
## Status ## Status
In progress — Increment 2 Button `onClick` vertical increment complete; request-input UI next In progress — component events and request-input authoring complete; response bindings and variables next
## Objective ## Objective
@ -33,7 +33,7 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
- [ ] Decide and implement the canonical timeout field before exposing a timeout control; the current schema and runtime use a fixed backend timeout. - [ ] Decide and implement the canonical timeout field before exposing a timeout control; the current schema and runtime use a fixed backend timeout.
- [x] Keep Slice 2 authentication authoring anonymous-only and defer credential-backed authentication controls to Slice 3. - [x] Keep Slice 2 authentication authoring anonymous-only and defer credential-backed authentication controls to Slice 3.
- [x] Implement component event configuration for Button `onClick`, including add/change/clear and dangling-reference diagnostics. - [x] Implement component event configuration for Button `onClick`, including add/change/clear and dangling-reference diagnostics.
- [ ] Implement request input authoring through executed REST request templates; do not write inert `ComponentEvent.inputMap`. - [x] Implement request input authoring through executed REST request templates; do not write inert `ComponentEvent.inputMap`.
- [ ] Implement response source and component/variable target selection. - [ ] Implement response source and component/variable target selection.
- [ ] Default new action-response bindings to `onSuccess`. - [ ] Default new action-response bindings to `onSuccess`.
- [ ] Implement page-load action configuration for initial data population. - [ ] Implement page-load action configuration for initial data population.
@ -92,6 +92,6 @@ Each increment writes only canonical configuration into the shared project docum
## Handoff ## Handoff
- Last completed: Increment 2 visual Button `onClick` configuration, automated validation, and user manual acceptance. - Last completed: Increment 2 visual request-input authoring for component and variable values, with automated validation.
- Next action: Add request-input UI that writes executed REST templates for component and variable values. - Next action: Implement visual response bindings and variable authoring, defaulting new action-response bindings to `onSuccess`.
- Known blockers: None for request-input implementation. Standalone frontend `tsc` retains its recorded tooling limitation. - Known blockers: Manual request-input acceptance remains pending. Standalone frontend `tsc` retains its recorded tooling limitation.

View File

@ -72,6 +72,7 @@ This document summarizes the current implementation and the remaining work requi
- [x] Implement a visual REST Action editor for creating, editing, duplicating, and deleting anonymous actions without hand-editing JSON. - [x] Implement a visual REST Action editor for creating, editing, duplicating, and deleting anonymous actions without hand-editing JSON.
- [x] Implement visual Button `onClick` configuration with add/change/clear, missing-reference diagnostics, canonical synchronization, and Preview execution. - [x] Implement visual Button `onClick` configuration with add/change/clear, missing-reference diagnostics, canonical synchronization, and Preview execution.
- [x] Implement visual component/variable request-input references through executed REST request templates.
- [ ] Implement a visual project-binding editor for request inputs and response targets. - [ ] Implement a visual project-binding editor for request inputs and response targets.
- [ ] Implement page-load action configuration for automatically populated components. - [ ] Implement page-load action configuration for automatically populated components.
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification. - [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.

View File

@ -700,6 +700,21 @@
color: #1e40af; color: #1e40af;
} }
.requestInputEditor {
padding: 12px;
border: 1px solid #d8dee4;
border-radius: 6px;
background: #f6f8fa;
}
.requestInputRow {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto;
gap: 8px;
align-items: end;
margin-top: 8px;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.formGrid, .formGrid,
.endpointRow, .endpointRow,

View File

@ -1031,6 +1031,8 @@ function ActionInspector(): React.ReactElement {
key={`${action.id}-${actionIndex}`} key={`${action.id}-${actionIndex}`}
action={action} action={action}
actions={actions} actions={actions}
components={allComponents}
variables={variables}
onChange={(updatedAction) => onChange={(updatedAction) =>
handleUpdateAction(actionIndex, updatedAction) handleUpdateAction(actionIndex, updatedAction)
} }

View File

@ -0,0 +1,60 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';
import type { RestAction } from '../../types/project';
import RequestInputEditor from './RequestInputEditor';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function selectValue(element: HTMLSelectElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!;
act(() => {
setter.call(element, value);
element.dispatchEvent(new Event('change', { bubbles: true }));
});
}
describe('RequestInputEditor', () => {
let container: HTMLDivElement;
let root: Root;
const action: RestAction = {
id: 'action', name: 'Action', method: 'GET', url: 'https://example.com',
queryParameters: { item: '' }, authenticationType: 'anonymous',
};
beforeEach(() => {
container = document.createElement('div');
root = createRoot(container);
});
afterEach(() => act(() => root.unmount()));
test('selects a component source and immediately emits an executed query template', () => {
const onChange = jest.fn();
act(() => root.render(
<RequestInputEditor
action={action}
components={[{
id: 'input', name: 'itemInput', type: 'TextInput',
position: { x: 0, y: 0 }, size: { width: 100, height: 40 }, properties: {},
}]}
variables={{ environment: { type: 'string', defaultValue: 'dev' } }}
onChange={onChange}
/>,
));
selectValue(container.querySelector('[data-testid="request-input-source"]')!, 'component:input');
selectValue(container.querySelector('[data-testid="request-input-target"]')!, 'queryParameters:item');
act(() => container.querySelector<HTMLButtonElement>('button')!.click());
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
queryParameters: { item: '{{components.itemInput.value}}' },
}));
});
test('does not offer inert path-parameter destinations', () => {
act(() => root.render(
<RequestInputEditor action={{ ...action, pathParameters: { itemId: '42' } }} components={[]} variables={{}} onChange={jest.fn()} />,
));
expect(container.textContent).not.toContain('Path');
});
});

View File

@ -0,0 +1,73 @@
import React, { useMemo, useState } from 'react';
import type { CanvasComponent, RestAction, Variable } from '../../types/project';
import { insertRequestInput, requestInputSources } from './requestInputUtils';
import type { RequestInputTarget } from './requestInputUtils';
import styles from './ActionInspector.module.css';
type Props = {
action: RestAction;
components: CanvasComponent[];
variables: Record<string, Variable>;
onChange: (action: RestAction) => void;
};
function encodeTarget(target: RequestInputTarget): string {
return target.location === 'headers' || target.location === 'queryParameters'
? `${target.location}:${target.key}`
: target.location;
}
function decodeTarget(value: string): RequestInputTarget | null {
if (value === 'url' || value === 'bodyTemplate') return { location: value };
const separator = value.indexOf(':');
if (separator < 0) return null;
const location = value.slice(0, separator);
const key = value.slice(separator + 1);
if ((location === 'headers' || location === 'queryParameters') && key) return { location, key };
return null;
}
export default function RequestInputEditor({ action, components, variables, onChange }: Props): React.ReactElement {
const sources = useMemo(() => requestInputSources(components, variables), [components, variables]);
const targets = useMemo<RequestInputTarget[]>(() => [
{ location: 'url' },
...Object.keys(action.headers ?? {}).map((key) => ({ location: 'headers' as const, key })),
...Object.keys(action.queryParameters ?? {}).map((key) => ({ location: 'queryParameters' as const, key })),
{ location: 'bodyTemplate' },
], [action.headers, action.queryParameters]);
const [sourceId, setSourceId] = useState('');
const [targetValue, setTargetValue] = useState('');
const source = sources.find((candidate) => candidate.id === sourceId);
const target = decodeTarget(targetValue);
return (
<div className={styles.requestInputEditor} data-testid="request-input-editor">
<div className={styles.formLabel}>Request value reference</div>
<div className={styles.formHint}>
Insert an executed component or variable template. Header and query destinations replace that value; URL and body destinations append.
</div>
<div className={styles.requestInputRow}>
<select className={styles.formSelect} data-testid="request-input-source" value={sourceId} onChange={(event) => setSourceId(event.target.value)}>
<option value="">Select component or variable</option>
{sources.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.label}</option>)}
</select>
<select className={styles.formSelect} data-testid="request-input-target" value={targetValue} onChange={(event) => setTargetValue(event.target.value)}>
<option value="">Select request destination</option>
{targets.map((candidate) => {
const value = encodeTarget(candidate);
const label = candidate.location === 'url'
? 'Endpoint URL (append)'
: candidate.location === 'bodyTemplate'
? 'Body template (append)'
: `${candidate.location === 'headers' ? 'Header' : 'Query'}: ${candidate.key}`;
return <option key={value} value={value}>{label}</option>;
})}
</select>
<button type="button" className={styles.smallButton} disabled={!source || !target} onClick={() => {
if (source && target) onChange(insertRequestInput(action, target, source.template));
}}>Insert reference</button>
</div>
{sources.length === 0 && <div className={styles.formHint}>Add a component or declare a variable before inserting a request value.</div>}
</div>
);
}

View File

@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
AuthenticationType, AuthenticationType,
CanvasComponent,
HttpMethod, HttpMethod,
RestAction, RestAction,
Variable,
} from '../../types/project'; } from '../../types/project';
import { import {
HTTP_METHODS, HTTP_METHODS,
@ -16,6 +18,7 @@ import type {
RequestMapKeyKind, RequestMapKeyKind,
} from './actionEditorUtils'; } from './actionEditorUtils';
import styles from './ActionInspector.module.css'; import styles from './ActionInspector.module.css';
import RequestInputEditor from './RequestInputEditor';
const AUTHENTICATION_OPTIONS: Array<{ const AUTHENTICATION_OPTIONS: Array<{
value: AuthenticationType; value: AuthenticationType;
@ -175,6 +178,8 @@ function KeyValueEditor({
export type RestActionEditorProps = { export type RestActionEditorProps = {
action: RestAction; action: RestAction;
actions: RestAction[]; actions: RestAction[];
components: CanvasComponent[];
variables: Record<string, Variable>;
onChange: (action: RestAction) => void; onChange: (action: RestAction) => void;
onDone: () => void; onDone: () => void;
}; };
@ -182,6 +187,8 @@ export type RestActionEditorProps = {
function RestActionEditor({ function RestActionEditor({
action, action,
actions, actions,
components,
variables,
onChange, onChange,
onDone, onDone,
}: RestActionEditorProps): React.ReactElement { }: RestActionEditorProps): React.ReactElement {
@ -410,6 +417,12 @@ function RestActionEditor({
<details className={styles.actionEditorDetails} open> <details className={styles.actionEditorDetails} open>
<summary>Request parameters</summary> <summary>Request parameters</summary>
<div className={styles.detailsBody}> <div className={styles.detailsBody}>
<RequestInputEditor
action={action}
components={components}
variables={variables}
onChange={onChange}
/>
<KeyValueEditor <KeyValueEditor
key={`${action.id}-headers`} key={`${action.id}-headers`}
fieldName="headers" fieldName="headers"

View File

@ -0,0 +1,43 @@
import type { RestAction } from '../../types/project';
import { insertRequestInput, requestInputSources } from './requestInputUtils';
const action: RestAction = {
id: 'action',
name: 'Action',
method: 'POST',
url: 'https://example.com/items/',
headers: { 'X-Item': 'static' },
queryParameters: { environment: 'dev' },
bodyTemplate: '{"id":"',
authenticationType: 'anonymous',
};
describe('visual request input authoring', () => {
test('builds canonical component and variable templates', () => {
expect(requestInputSources([
{
id: 'input', name: 'itemInput', type: 'TextInput',
position: { x: 0, y: 0 }, size: { width: 100, height: 40 }, properties: {},
},
], { environment: { type: 'string', defaultValue: 'dev' } })).toEqual([
{ id: 'component:input', label: 'Component: itemInput', template: '{{components.itemInput.value}}' },
{ id: 'variable:environment', label: 'Variable: environment', template: '{{variables.environment}}' },
]);
});
test('inserts references into every executed request-template location', () => {
expect(insertRequestInput(action, { location: 'url' }, '{{variables.id}}').url)
.toBe('https://example.com/items/{{variables.id}}');
expect(insertRequestInput(action, { location: 'headers', key: 'X-Item' }, '{{variables.id}}').headers)
.toEqual({ 'X-Item': '{{variables.id}}' });
expect(insertRequestInput(action, { location: 'queryParameters', key: 'environment' }, '{{variables.environment}}').queryParameters)
.toEqual({ environment: '{{variables.environment}}' });
expect(insertRequestInput(action, { location: 'bodyTemplate' }, '{{components.itemInput.value}}').bodyTemplate)
.toBe('{"id":"{{components.itemInput.value}}');
});
test('does not mutate the source action', () => {
insertRequestInput(action, { location: 'headers', key: 'X-Item' }, '{{variables.id}}');
expect(action.headers).toEqual({ 'X-Item': 'static' });
});
});

View File

@ -0,0 +1,40 @@
import type { CanvasComponent, RestAction, Variable } from '../../types/project';
export type RequestInputSource = { id: string; label: string; template: string };
export type RequestInputTarget =
| { location: 'url' }
| { location: 'bodyTemplate' }
| { location: 'headers'; key: string }
| { location: 'queryParameters'; key: string };
export function requestInputSources(
components: CanvasComponent[],
variables: Record<string, Variable>,
): RequestInputSource[] {
return [
...components.map((component) => ({
id: `component:${component.id}`,
label: `Component: ${component.name}`,
template: `{{components.${component.name}.value}}`,
})),
...Object.keys(variables).map((name) => ({
id: `variable:${name}`,
label: `Variable: ${name}`,
template: `{{variables.${name}}}`,
})),
];
}
export function insertRequestInput(action: RestAction, target: RequestInputTarget, template: string): RestAction {
if (target.location === 'url') return { ...action, url: `${action.url}${template}` };
if (target.location === 'bodyTemplate') {
return { ...action, bodyTemplate: `${action.bodyTemplate ?? ''}${template}` };
}
return {
...action,
[target.location]: {
...(action[target.location] ?? {}),
[target.key]: template,
},
};
}