Add visual REST request input authoring
This commit is contained in:
parent
3994eaa397
commit
6fd084c8b3
@ -187,3 +187,9 @@ Remaining release work includes broader backend tests, browser end-to-end tests,
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
10
CODEX.md
10
CODEX.md
@ -201,3 +201,13 @@ Date: 2026-07-19
|
||||
- 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.
|
||||
- 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
32
NEXT_SESSION_PROMPT.md
Normal 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.
|
||||
10
SLICE2.md
10
SLICE2.md
@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
@ -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.
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
- [ ] Default new action-response bindings to `onSuccess`.
|
||||
- [ ] 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
|
||||
|
||||
- Last completed: Increment 2 visual Button `onClick` configuration, automated validation, and user manual acceptance.
|
||||
- Next action: Add request-input UI that writes executed REST templates for component and variable values.
|
||||
- Known blockers: None for request-input implementation. Standalone frontend `tsc` retains its recorded tooling limitation.
|
||||
- Last completed: Increment 2 visual request-input authoring for component and variable values, with automated validation.
|
||||
- Next action: Implement visual response bindings and variable authoring, defaulting new action-response bindings to `onSuccess`.
|
||||
- Known blockers: Manual request-input acceptance remains pending. Standalone frontend `tsc` retains its recorded tooling limitation.
|
||||
|
||||
1
TASKS.md
1
TASKS.md
@ -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 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 page-load action configuration for automatically populated components.
|
||||
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
|
||||
|
||||
@ -700,6 +700,21 @@
|
||||
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) {
|
||||
.formGrid,
|
||||
.endpointRow,
|
||||
|
||||
@ -1031,6 +1031,8 @@ function ActionInspector(): React.ReactElement {
|
||||
key={`${action.id}-${actionIndex}`}
|
||||
action={action}
|
||||
actions={actions}
|
||||
components={allComponents}
|
||||
variables={variables}
|
||||
onChange={(updatedAction) =>
|
||||
handleUpdateAction(actionIndex, updatedAction)
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
AuthenticationType,
|
||||
CanvasComponent,
|
||||
HttpMethod,
|
||||
RestAction,
|
||||
Variable,
|
||||
} from '../../types/project';
|
||||
import {
|
||||
HTTP_METHODS,
|
||||
@ -16,6 +18,7 @@ import type {
|
||||
RequestMapKeyKind,
|
||||
} from './actionEditorUtils';
|
||||
import styles from './ActionInspector.module.css';
|
||||
import RequestInputEditor from './RequestInputEditor';
|
||||
|
||||
const AUTHENTICATION_OPTIONS: Array<{
|
||||
value: AuthenticationType;
|
||||
@ -175,6 +178,8 @@ function KeyValueEditor({
|
||||
export type RestActionEditorProps = {
|
||||
action: RestAction;
|
||||
actions: RestAction[];
|
||||
components: CanvasComponent[];
|
||||
variables: Record<string, Variable>;
|
||||
onChange: (action: RestAction) => void;
|
||||
onDone: () => void;
|
||||
};
|
||||
@ -182,6 +187,8 @@ export type RestActionEditorProps = {
|
||||
function RestActionEditor({
|
||||
action,
|
||||
actions,
|
||||
components,
|
||||
variables,
|
||||
onChange,
|
||||
onDone,
|
||||
}: RestActionEditorProps): React.ReactElement {
|
||||
@ -410,6 +417,12 @@ function RestActionEditor({
|
||||
<details className={styles.actionEditorDetails} open>
|
||||
<summary>Request parameters</summary>
|
||||
<div className={styles.detailsBody}>
|
||||
<RequestInputEditor
|
||||
action={action}
|
||||
components={components}
|
||||
variables={variables}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<KeyValueEditor
|
||||
key={`${action.id}-headers`}
|
||||
fieldName="headers"
|
||||
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
40
frontend/src/components/ActionInspector/requestInputUtils.ts
Normal file
40
frontend/src/components/ActionInspector/requestInputUtils.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user