From 6fd084c8b3f5f13b8542406c0f54b7f74efc2638 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Sun, 19 Jul 2026 08:58:08 -0400 Subject: [PATCH] Add visual REST request input authoring --- BASELINE.md | 6 ++ CODEX.md | 10 +++ NEXT_SESSION_PROMPT.md | 32 ++++++++ SLICE2.md | 10 +-- TASKS.md | 1 + .../ActionInspector.module.css | 15 ++++ .../ActionInspector/ActionInspector.tsx | 2 + .../RequestInputEditor.test.tsx | 60 +++++++++++++++ .../ActionInspector/RequestInputEditor.tsx | 73 +++++++++++++++++++ .../ActionInspector/RestActionEditor.tsx | 13 ++++ .../ActionInspector/requestInputUtils.test.ts | 43 +++++++++++ .../ActionInspector/requestInputUtils.ts | 40 ++++++++++ 12 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 NEXT_SESSION_PROMPT.md create mode 100644 frontend/src/components/ActionInspector/RequestInputEditor.test.tsx create mode 100644 frontend/src/components/ActionInspector/RequestInputEditor.tsx create mode 100644 frontend/src/components/ActionInspector/requestInputUtils.test.ts create mode 100644 frontend/src/components/ActionInspector/requestInputUtils.ts diff --git a/BASELINE.md b/BASELINE.md index 17dcee0..f01bc7e 100644 --- a/BASELINE.md +++ b/BASELINE.md @@ -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. diff --git a/CODEX.md b/CODEX.md index 4a162ca..fe3c510 100644 --- a/CODEX.md +++ b/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..value}}` and `{{variables.}}` 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. diff --git a/NEXT_SESSION_PROMPT.md b/NEXT_SESSION_PROMPT.md new file mode 100644 index 0000000..703357f --- /dev/null +++ b/NEXT_SESSION_PROMPT.md @@ -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. diff --git a/SLICE2.md b/SLICE2.md index 18a6ff5..3d7a7c7 100644 --- a/SLICE2.md +++ b/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. diff --git a/TASKS.md b/TASKS.md index df39e0e..69013fd 100644 --- a/TASKS.md +++ b/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. diff --git a/frontend/src/components/ActionInspector/ActionInspector.module.css b/frontend/src/components/ActionInspector/ActionInspector.module.css index aa4e7c1..1e63f16 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.module.css +++ b/frontend/src/components/ActionInspector/ActionInspector.module.css @@ -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, diff --git a/frontend/src/components/ActionInspector/ActionInspector.tsx b/frontend/src/components/ActionInspector/ActionInspector.tsx index 71885eb..fc34b20 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.tsx +++ b/frontend/src/components/ActionInspector/ActionInspector.tsx @@ -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) } diff --git a/frontend/src/components/ActionInspector/RequestInputEditor.test.tsx b/frontend/src/components/ActionInspector/RequestInputEditor.test.tsx new file mode 100644 index 0000000..2a23c1f --- /dev/null +++ b/frontend/src/components/ActionInspector/RequestInputEditor.test.tsx @@ -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( + , + )); + + selectValue(container.querySelector('[data-testid="request-input-source"]')!, 'component:input'); + selectValue(container.querySelector('[data-testid="request-input-target"]')!, 'queryParameters:item'); + act(() => container.querySelector('button')!.click()); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + queryParameters: { item: '{{components.itemInput.value}}' }, + })); + }); + + test('does not offer inert path-parameter destinations', () => { + act(() => root.render( + , + )); + expect(container.textContent).not.toContain('Path'); + }); +}); diff --git a/frontend/src/components/ActionInspector/RequestInputEditor.tsx b/frontend/src/components/ActionInspector/RequestInputEditor.tsx new file mode 100644 index 0000000..c45dca0 --- /dev/null +++ b/frontend/src/components/ActionInspector/RequestInputEditor.tsx @@ -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; + 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(() => [ + { 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 ( +
+
Request value reference
+
+ Insert an executed component or variable template. Header and query destinations replace that value; URL and body destinations append. +
+
+ + + +
+ {sources.length === 0 &&
Add a component or declare a variable before inserting a request value.
} +
+ ); +} diff --git a/frontend/src/components/ActionInspector/RestActionEditor.tsx b/frontend/src/components/ActionInspector/RestActionEditor.tsx index cc07a66..775f919 100644 --- a/frontend/src/components/ActionInspector/RestActionEditor.tsx +++ b/frontend/src/components/ActionInspector/RestActionEditor.tsx @@ -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; 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({
Request parameters
+ { + 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' }); + }); +}); diff --git a/frontend/src/components/ActionInspector/requestInputUtils.ts b/frontend/src/components/ActionInspector/requestInputUtils.ts new file mode 100644 index 0000000..2489235 --- /dev/null +++ b/frontend/src/components/ActionInspector/requestInputUtils.ts @@ -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, +): 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, + }, + }; +}