Add Slice 2 visual REST configuration

This commit is contained in:
Victor Wiebe 2026-07-19 08:35:55 -04:00
parent b9697cc8b1
commit 3994eaa397
21 changed files with 2463 additions and 79 deletions

View File

@ -79,6 +79,77 @@ User validation completed successfully on 2026-07-18:
- Save/load persistence and existing-component regression checks pass.
## Slice 2 Increment 1 Validation
Validation completed on 2026-07-18 for visual anonymous REST action authoring.
### Focused editor tests
Command:
```bash
npm test -- --watchAll=false --runInBand --runTestsByPath \
src/components/ActionInspector/actionEditorUtils.test.ts \
src/components/ActionInspector/ActionInspector.test.tsx
```
Result: Passed.
- Test suites: 2 passed, 2 total
- Tests: 23 passed, 23 total
- Covers canonical CRUD, duplicate-ID safety, reference discovery, request-map and URL validation, anonymous duplication, immediate document synchronization, and invalid local drafts.
### Full target-toolchain checks
Commands:
```bash
npx --yes --package=node@20 --package=npm@10 npm test -- --watchAll=false --runInBand
npx --yes --package=node@20 --package=npm@10 npm run build
```
The frontend commands ran from `frontend/`. The build command also ran from `backend/`.
Results:
- Frontend tests: 9 suites and 456 tests passed; 0 snapshots.
- Frontend production build: passed.
- Backend TypeScript build: passed.
### Schema fixture matrix
Each fixture was checked with:
```bash
./node_modules/.bin/ajv validate \
-s shared/schemas/conductor-project.schema.json \
-d <fixture> \
--spec=draft2020
```
Results:
- 13 of 13 `valid-*.json` fixtures passed, including `valid-visual-rest-actions.json`.
- 2 of 2 schema-invalid fixtures failed as expected.
- 2 of 2 diagnostic-invalid fixtures passed structural validation as designed.
### Standalone frontend TypeScript check
Command:
```bash
npx tsc --noEmit
```
Result: Blocked by an existing dependency/toolchain mismatch. Frontend TypeScript 4.9 cannot parse syntax in resolved `@types/node@26.1.0` (first error: `node_modules/@types/node/ffi.d.ts(94,21): TS1139`). The CRA production build performs the application TypeScript compile and passed.
### Visual QA
The frontend development server compiled successfully at `http://localhost:3000`. In-app browser automation could not start during implementation while Docker Desktop was not running; its Node REPL kernel reported `helper_unknown_error: setup refresh had errors` during Windows sandbox setup.
After Docker Desktop and the app were started, the user completed the Slice 2 Increment 1 manual acceptance on 2026-07-19. The accepted workflow covered visual anonymous REST action authoring, a Button `events` entry inserted through canonical JSON for an `onClick` action reference, and referenced-action deletion confirmation. Full Slice 2 workflow-launcher and dependent-data acceptance remains pending later increments.
## Runtime Startup
Initial backend startup against a clean SQLite database failed with:
@ -112,3 +183,7 @@ Passing for the current automated and Docker smoke-test scope:
- [x] Docker Compose build and integration checks pass
Remaining release work includes broader backend tests, browser end-to-end tests, security regression tests, and resolution of the frontend dependency audit findings.
## 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.

View File

@ -82,6 +82,9 @@ All eleven schema-supported MVP components are available in the palette and have
### REST and Preview runtime
- REST action model supporting GET, POST, PUT, PATCH, and DELETE
- Visual anonymous REST action creation, editing, duplication, and reference-aware deletion in Actions & Bindings
- Progressive request controls for endpoint URL, headers, query parameters, static path parameters, and request bodies
- Valid action edits synchronize immediately to the canonical document; invalid form drafts stay local
- Anonymous server-side REST execution
- Request URL, header, query, path, and body templates
- Normalized proxy response envelope
@ -101,7 +104,7 @@ All eleven schema-supported MVP components are available in the palette and have
- Secure credential storage and secret resolution are not implemented.
- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation.
- Sanitized execution history and troubleshooting views are not implemented.
- Visual REST action, event, and binding configuration workflows are incomplete; advanced configuration still relies on JSON editing.
- Visual REST action authoring is implemented; component events, request inputs, response bindings, variables, and page-load actions still rely on JSON editing.
- Project documents are validated in the JSON Editor, but save operations still need an unconditional validation gate.
- Backend and end-to-end automated test coverage remains incomplete.
- IBM Bob/watsonx will not be used; AI is provider-neutral and post-MVP.
@ -117,8 +120,10 @@ The initial repository push was completed on 2026-07-18.
- The working tree was clean immediately after the push.
- All `:Zone.Identifier` sidecar files were removed before the initial commit.
- Node 20/npm 10 clean installs pass after repairing the frontend lockfile.
- Frontend validation passes: 7 suites and 433 tests, plus the production build.
- Backend build and the schema fixture matrix pass.
- Slice 2 Increment 1 frontend validation passes under Node 20/npm 10: 9 suites and 456 tests, plus the production build.
- Backend build and the schema fixture matrix pass: 13 valid fixtures, 2 expected schema failures, and 2 diagnostic-invalid fixtures that remain structurally valid.
- Standalone frontend `tsc --noEmit` is blocked by the existing TypeScript 4.9 / `@types/node@26.1.0` dependency mismatch; the CRA TypeScript production compile passes.
- In-app browser automation could not start during implementation while Docker Desktop was not running. After Docker Desktop and the app were started, the user manually accepted Slice 2 Increment 1 on 2026-07-19, including a Button `onClick` event inserted through canonical JSON and reference-aware action deletion.
- Docker Compose builds and starts both services.
- Health, CRUD, cleanup, and restart persistence pass.
- See `BASELINE.md` for exact evidence and remaining release work.
@ -128,12 +133,13 @@ The initial repository push was completed on 2026-07-18.
Unless the user chooses a different priority, proceed in this order:
1. Add visual REST action, event, and binding configuration.
2. Add validation to every project save path.
3. Implement authentication and secure secret handling.
4. Harden the REST proxy and add sanitized execution logging.
5. Add backend, end-to-end, and security regression tests.
6. Reconcile and consolidate project documentation.
1. Add visual component-event configuration, beginning with Button `onClick`, then request input mapping.
2. Add visual response bindings, variables, and page-load action configuration.
3. Add validation to every project save path.
4. Implement authentication and secure secret handling.
5. Harden the REST proxy and add sanitized execution logging.
6. Add backend, end-to-end, and security regression tests.
7. Reconcile and consolidate project documentation.
See `TASKS.md` for the complete actionable checklist.
@ -172,12 +178,26 @@ After material work:
## Latest Handoff
Date: 2026-07-18
Date: 2026-07-19
- Completed Slice 1 by implementing Text Area, Checkbox, Radio Group, Status Panel, and Container/Card across the palette, canvas, property editor, and Preview.
- Added runtime value and response-binding support while keeping ephemeral values out of canonical JSON.
- Scoped Container/Card to a flat presentational title/body contract for MVP; nesting remains post-MVP.
- Added `valid-mvp-components.json` and focused renderer/binding tests.
- Validation passes: 7 frontend suites / 433 tests, frontend production build, backend TypeScript build, and the new schema fixture.
- User completed the full manual Slice 1 checklist successfully.
- Next recommended action: begin `SLICE2.md`.
- Completed Slice 2 Increment 1: visual anonymous REST action creation, editing, duplication, and reference-aware deletion.
- Added method, URL, header, query, static path-parameter, and body controls with immediate canonical-document synchronization.
- Kept invalid drafts and all execution state outside canonical JSON; new and duplicated actions are anonymous and omit deprecated `action.responseMapping`.
- Added focused validation for action IDs, URLs, duplicate IDs, header names, unsupported path-parameter templates, and referenced deletion.
- Added `valid-visual-rest-actions.json` and 23 focused tests.
- Validation passes under Node 20/npm 10: 9 frontend suites / 456 tests, frontend production build, backend TypeScript build, and the 13 / 2 / 2 schema matrix.
- The user manually accepted the Increment 1 REST action authoring and referenced-action deletion workflow after starting Docker Desktop and the app.
- Standalone frontend `tsc --noEmit` remains blocked by the existing dependency mismatch. Browser automation remained unavailable; user manual acceptance passed.
- No commit or push was made; the work remains uncommitted on top of `b9697cc` as requested.
- Next recommended action: Slice 2 Increment 2, starting with visual Button `onClick` action selection and request inputs.
### Increment 2 Addendum
- Completed visual Button `onClick` action selection with no-action, add/change/clear, unrelated-event preservation, and missing-action diagnostics.
- Canonical `component.events` updates immediately; automated coverage confirms Preview executes the selected REST action.
- Reconciled request inputs: `ComponentEvent.inputMap` is compatibility-only and is not consumed. Request-input UI must write executed REST request templates.
- Updated schema/type documentation and the representative visual REST fixture.
- Validation passes: 12 frontend suites / 462 tests, frontend production build, backend build, and the 13 / 2 / 2 schema matrix.
- 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.

View File

@ -2,7 +2,7 @@
## Status
Not started
In progress — Increment 2 Button `onClick` vertical increment complete; request-input UI next
## Objective
@ -27,18 +27,30 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
## Tasks
- [ ] Design a consistent workflow for editing actions, events, bindings, and variables.
- [ ] Implement REST Action list, create, edit, duplicate, and delete operations.
- [ ] Add method, URL, headers, query, path, body, timeout, and authentication-type controls.
- [ ] Implement component event configuration, beginning with Button `onClick`.
- [ ] Implement request input mapping from components and variables.
- [x] Design a consistent, progressively disclosed workflow for editing actions, events, bindings, variables, and page-load behavior.
- [x] Implement REST Action list, create, edit, duplicate, and reference-aware delete operations.
- [x] Add method, URL, headers, query, path, and body controls for anonymous REST actions.
- [ ] 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`.
- [ ] Implement response source and component/variable target selection.
- [ ] Default new action-response bindings to `onSuccess`.
- [ ] Implement page-load action configuration for initial data population.
- [ ] Add variable declaration and default-value editing.
- [ ] Warn before deleting referenced actions, variables, or components.
- [ ] Ensure every visual edit immediately updates canonical JSON.
- [ ] Add tests, examples, and documentation updates.
- [ ] Warn before deleting referenced actions, variables, or components. Action deletion is covered; variable and component deletion remain.
- [x] Ensure every REST action visual edit immediately updates canonical JSON while invalid drafts remain local.
- [ ] Add slice-wide tests, examples, and documentation updates. Increment 1 coverage and a representative REST fixture are complete.
## Implementation Order
1. **REST action authoring:** Add canonical action CRUD, method/URL/request controls, local validation, diagnostics, reference-aware deletion warnings, focused tests, and a representative example project.
2. **Component events and request inputs:** Configure Button `onClick` first, then supported `onChange` events and component/variable request templates.
3. **Response bindings and variables:** Author top-level `project.bindings` with `onSuccess` defaults, supported response paths and targets, plus variable declarations/defaults.
4. **Page-load actions:** Add the minimum page lifecycle contract needed to execute configured `onLoad` actions without introducing general orchestration.
5. **Slice acceptance and hardening:** Complete dangling-reference diagnostics, persistence/JSON synchronization coverage, representative examples, builds, schema validation, and manual workflows.
Each increment writes only canonical configuration into the shared project document. Preview execution values, loading flags, responses, errors, and variable values remain ephemeral runtime state.
## Acceptance Criteria
@ -50,9 +62,11 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
## Validation
- [ ] Frontend TypeScript check and production build pass.
- [ ] Editor interaction tests pass.
- [ ] Generated canonical JSON validates against the schema.
- [ ] Standalone frontend TypeScript check passes. TypeScript 4.9 currently cannot parse the resolved `@types/node@26.1.0`; the CRA production compile passes.
- [x] Frontend production build passes through the Button `onClick` increment.
- [x] Editor interaction and Preview dispatch tests pass through the Button `onClick` increment.
- [x] Increment 2 representative canonical JSON validates against the schema.
- [x] Increment 1 manual REST action authoring and referenced-action deletion workflow passes.
- [ ] Manual workflow-launcher and dependent-data scenarios pass.
## Risks and Open Questions
@ -63,10 +77,21 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
## Progress Log
No work recorded yet.
- 2026-07-18: Restored project state from `CODEX.md`, `TASKS.md`, `MVP_SCOPE.md`, and this plan.
- Verified the clean `main` branch against a freshly fetched `origin/main`; both point to `b9697cc8b11bce806887d6e7a29098a308b0846a`.
- Re-ran the pre-change frontend baseline successfully: 7 suites and 433 tests passed.
- Audited the canonical action/event/binding model, Visual Editor, read-only Actions & Bindings Inspector, Preview execution path, schema validation, examples, and existing tests.
- Chose visual REST action authoring as the first coherent vertical increment.
- Completed canonical anonymous REST action creation, editing, duplication, and reference-aware deletion in Actions & Bindings.
- Added progressive request controls for method, endpoint URL, headers, query parameters, static path parameters, and request body; invalid drafts remain local and runtime state remains separate.
- Added action-definition, duplicate-ID, header-name, and path-template diagnostics plus referenced-deletion warnings.
- Added 23 focused tests plus `valid-visual-rest-actions.json`.
- Validation passed under Node 20/npm 10: 9 frontend suites / 456 tests, frontend production build, backend TypeScript build, and the 13-valid / 2-schema-invalid / 2-diagnostic-invalid schema matrix.
- Standalone `tsc --noEmit` remains blocked by the existing TypeScript 4.9 / `@types/node@26.1.0` dependency mismatch; the application production build compiles successfully.
- In-app browser automation could not start during implementation while Docker Desktop was not running. After Docker Desktop and the app were started, the user manually accepted Increment 1 on 2026-07-19, including a Button `onClick` event inserted through canonical JSON and the referenced-action deletion workflow.
## Handoff
- Last completed: Slice plan created.
- Next action: Design the action editor data flow and acceptance-test project.
- Known blockers: Coordinate secret-reference fields with Slice 3.
- 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.

View File

@ -70,8 +70,8 @@ This document summarizes the current implementation and the remaining work requi
### Visual configuration workflows
- [ ] Implement a visual REST Action editor for creating, editing, and deleting actions without hand-editing JSON.
- [ ] Implement visual component-event configuration, including binding a button to an action.
- [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.
- [ ] 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.

View File

@ -110,14 +110,18 @@ Allowed `type` values:
"events": [
{
"event": "onClick",
"actionId": "action_trigger_workflow",
"inputMap": { "workflowId": "components.workflowIdInput.value" }
"actionId": "action_trigger_workflow"
}
],
"bindings": []
}
```
`ComponentEvent.inputMap` is retained in schema version `0.1.0` for compatibility
but is not consumed by Preview. Request values are configured in REST action
templates using `{{components.<name>.value}}` or `{{variables.<name>}}`.
Visual request-input authoring must write those executed request fields.
### `Action` (REST Action)
A REST API call definition. Required fields: `id`, `name`, `method`, `url`, `authenticationType`.
@ -144,10 +148,18 @@ Allowed `authenticationType` values: `anonymous` · `bearerToken` · `basicAuth`
"workflowId": "{{workflowId}}"
},
"bodyTemplate": "{\"params\": {{params}}}",
"authenticationType": "bearerToken",
"responseMapping": [
{ "source": "data.status", "target": "variables.lastStatus" }
]
"authenticationType": "bearerToken"
}
```
New response mappings belong in top-level `project.bindings`, not inside the action:
```json
{
"id": "bind_trigger_status",
"source": "actions.action_trigger_workflow.response.body.data.status",
"target": "variables.lastStatus",
"trigger": "onSuccess"
}
```
@ -167,7 +179,7 @@ Allowed `authenticationType` values: `anonymous` · `bearerToken` · `basicAuth`
| `pathParameters` | `object` | — | Path segment substitutions. Keys match `{{paramName}}` in the URL. |
| `bodyTemplate` | `string` | — | Request body template. Use `{{variableName}}` for runtime substitutions. |
| `authenticationType` | `string` | ✅ | Authentication strategy (see allowed values above). Credentials are never stored here. |
| `responseMapping` | `array` | — | Rules mapping response fields to component properties or variables. |
| `responseMapping` | `array` | — | Deprecated legacy action-local mappings. New mappings use top-level `project.bindings`. |
### `Binding`
@ -220,6 +232,7 @@ Optional canvas and display configuration.
| `examples/project-definitions/valid-minimal.json` | Smallest valid project definition (one empty page). |
| `examples/project-definitions/valid-full.json` | Full example: Concert Workflow Launcher with all features. |
| `examples/project-definitions/valid-rest-actions.json` | Showcases all five authentication types across seven REST actions. No canvas components — validates the action model in isolation. |
| `examples/project-definitions/valid-visual-rest-actions.json` | Slice 2 visual-authoring fixture covering anonymous GET/POST request fields. |
| `examples/project-definitions/invalid-missing-required.json` | Intentionally invalid document showing schema errors. |
---

View File

@ -0,0 +1,90 @@
{
"$schema": "../../shared/schemas/conductor-project.schema.json",
"schemaVersion": "0.1.0",
"project": {
"id": "proj_visual_rest_action_authoring",
"name": "Visual REST Action Authoring",
"description": "Slice 2 fixture covering anonymous visual REST actions, Button onClick assignment, and executed component request templates.",
"pages": [
{
"id": "page_main",
"name": "Main",
"order": 0,
"components": [
{
"id": "cmp_item_input",
"type": "TextInput",
"name": "itemInput",
"position": { "x": 24, "y": 24 },
"size": { "width": 260, "height": 44 },
"properties": {
"label": "Item ID",
"defaultValue": "demo-42",
"visible": true,
"disabled": false
}
},
{
"id": "cmp_lookup_button",
"type": "Button",
"name": "lookupButton",
"position": { "x": 24, "y": 88 },
"size": { "width": 160, "height": 44 },
"properties": {
"label": "Look up item",
"visible": true,
"disabled": false
},
"events": [
{ "event": "onClick", "actionId": "action_lookup_item" }
]
}
],
"events": []
}
],
"actions": [
{
"id": "action_lookup_item",
"name": "Look up item",
"description": "GET example with headers, query parameters, and a path parameter.",
"method": "GET",
"url": "https://httpbin.org/anything/{{itemId}}",
"headers": {
"Accept": "application/json",
"X-Conductor-Example": "slice-2"
},
"queryParameters": {
"include": "details",
"selectedItem": "{{components.itemInput.value}}",
"limit": "25"
},
"pathParameters": {
"itemId": "demo-42"
},
"bodyTemplate": "",
"authenticationType": "anonymous"
},
{
"id": "action_create_item",
"name": "Create item",
"description": "POST example with a JSON body template and canonical anonymous authentication.",
"method": "POST",
"url": "https://httpbin.org/anything",
"headers": {
"Accept": "application/json",
"Content-Type": "application/json"
},
"queryParameters": {
"dryRun": "true"
},
"pathParameters": {},
"bodyTemplate": "{\"name\":\"Example item\",\"source\":\"conductor-slice-2\"}",
"authenticationType": "anonymous"
}
],
"bindings": [],
"variables": {},
"settings": {}
}
}

View File

@ -447,3 +447,277 @@
border-radius: 3px;
padding: 1px 6px;
}
/* ── Slice 2 action authoring ─────────────────────────────────────── */
.sectionHeaderMeta {
display: flex;
align-items: center;
gap: 8px;
}
.actionControls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.primaryButton:disabled,
.smallButton:disabled,
.dangerButton:disabled,
.removeRowButton:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.primaryButton,
.smallButton,
.dangerButton,
.removeRowButton {
border-radius: 4px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
}
.primaryButton {
padding: 5px 10px;
border: 1px solid #1d4ed8;
background: #2563eb;
color: #ffffff;
font-size: 12px;
}
.primaryButton:not(:disabled):hover {
background: #1d4ed8;
}
.smallButton {
padding: 4px 9px;
border: 1px solid #d1d5db;
background: #ffffff;
color: #374151;
font-size: 11px;
}
.smallButton:not(:disabled):hover {
border-color: #6b7280;
background: #f7f8fa;
}
.dangerButton,
.removeRowButton {
padding: 4px 9px;
border: 1px solid #fecaca;
background: #fff7f7;
color: #b91c1c;
font-size: 11px;
}
.dangerButton:not(:disabled):hover,
.removeRowButton:not(:disabled):hover {
border-color: #ef4444;
background: #fef2f2;
}
.actionEditor {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
border-bottom: 1px solid #bfdbfe;
background: #f8fbff;
box-shadow: inset 3px 0 0 #2563eb;
}
.actionEditorHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.actionEditorTitle {
color: #1f2328;
font-size: 14px;
font-weight: 600;
}
.formGrid,
.endpointRow {
display: grid;
gap: 12px;
}
.formGrid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.endpointRow {
grid-template-columns: 130px minmax(0, 1fr);
}
.formField,
.methodField,
.urlField {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.formLabel {
color: #374151;
font-size: 11px;
font-weight: 600;
}
.formInput,
.formSelect,
.formTextarea {
box-sizing: border-box;
width: 100%;
border: 1px solid #d1d5db;
border-radius: 4px;
background: #ffffff;
color: #1f2328;
font-family: inherit;
font-size: 12px;
line-height: 1.4;
padding: 7px 8px;
}
.formInput:focus,
.formSelect:focus,
.formTextarea:focus {
border-color: #2563eb;
outline: 2px solid #bfdbfe;
outline-offset: 0;
}
.formInput[readonly] {
background: #f3f4f6;
color: #6b7280;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.formTextarea {
min-height: 58px;
resize: vertical;
}
.bodyTemplate {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
line-height: 1.55;
}
.formHint {
color: #6b7280;
font-size: 10px;
line-height: 1.45;
}
.actionEditorDetails {
overflow: hidden;
border: 1px solid #dbe3ed;
border-radius: 5px;
background: #ffffff;
}
.actionEditorDetails summary {
padding: 9px 11px;
background: #f6f8fa;
color: #374151;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.detailsBody {
display: flex;
flex-direction: column;
gap: 14px;
padding: 12px;
}
.keyValueEditor {
display: flex;
flex-direction: column;
gap: 6px;
}
.keyValueHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.keyValueRow {
display: grid;
grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1.3fr) auto;
align-items: start;
gap: 6px;
}
.removeRowButton {
min-height: 32px;
}
.inlineError {
padding: 5px 7px;
border: 1px solid #fde68a;
border-radius: 4px;
background: #fffbeb;
color: #92400e;
font-size: 10px;
line-height: 1.45;
}
.editorIssues {
display: flex;
flex-direction: column;
gap: 4px;
}
.editorIssueWarn,
.editorIssueInfo {
padding: 5px 8px;
border-radius: 4px;
font-size: 11px;
line-height: 1.45;
}
.editorIssueWarn {
border: 1px solid #fde68a;
background: #fffbeb;
color: #92400e;
}
.editorIssueInfo {
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #1e40af;
}
@media (max-width: 720px) {
.formGrid,
.endpointRow,
.keyValueRow {
grid-template-columns: 1fr;
}
.sectionHeader,
.actionEditorHeader {
align-items: stretch;
flex-direction: column;
}
.sectionHeaderMeta {
justify-content: space-between;
}
.removeRowButton {
justify-self: start;
}
}

View File

@ -0,0 +1,237 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';
import { ProjectProvider, useProject } from '../../context/ProjectContext';
import type { ProjectDocument } from '../../types/project';
import ActionInspector from './ActionInspector';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function ProjectProbe(): React.ReactElement {
const { doc, isDirty } = useProject();
return (
<>
<pre data-testid="project-probe">{JSON.stringify(doc)}</pre>
<span data-testid="dirty-probe">{String(isDirty)}</span>
</>
);
}
function currentDocument(container: HTMLElement): ProjectDocument {
const probe = container.querySelector('[data-testid="project-probe"]');
if (!probe?.textContent) throw new Error('Project probe was not rendered.');
return JSON.parse(probe.textContent) as ProjectDocument;
}
function click(element: Element | null): void {
if (!element) throw new Error('Expected clickable element was not found.');
act(() => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
function doneButton(container: HTMLElement): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>('button[type="submit"]');
if (!button) throw new Error('Done button was not found.');
return button;
}
function setControlValue(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null,
value: string,
): void {
if (!element) throw new Error('Expected form control was not found.');
const prototype = element instanceof HTMLInputElement
? HTMLInputElement.prototype
: element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLSelectElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (!setter) throw new Error('Could not find native value setter.');
act(() => {
setter.call(element, value);
element.dispatchEvent(new Event(
element instanceof HTMLSelectElement ? 'change' : 'input',
{ bubbles: true },
));
});
}
describe('Actions & Bindings visual REST action authoring', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root.render(
<ProjectProvider>
<ActionInspector />
<ProjectProbe />
</ProjectProvider>,
);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
jest.restoreAllMocks();
});
test('creates and edits an anonymous action in canonical project state', () => {
click(container.querySelector('[data-testid="add-rest-action"]'));
let doc = currentDocument(container);
expect(doc.project.actions).toHaveLength(1);
expect(doc.project.actions[0]).toMatchObject({
id: 'action_rest_1',
method: 'GET',
authenticationType: 'anonymous',
});
expect(doc.project.actions[0]).not.toHaveProperty('responseMapping');
expect(container.querySelector('[data-testid="dirty-probe"]')?.textContent)
.toBe('true');
setControlValue(
container.querySelector<HTMLSelectElement>('[data-testid="action-authentication"]'),
'bearerToken',
);
expect(currentDocument(container).project.actions[0].authenticationType)
.toBe('anonymous');
setControlValue(
container.querySelector<HTMLInputElement>('[data-testid="action-name"]'),
'Create inventory item',
);
setControlValue(
container.querySelector<HTMLSelectElement>('[data-testid="action-method"]'),
'POST',
);
setControlValue(
container.querySelector<HTMLInputElement>('[data-testid="action-url"]'),
'https://api.example.com/items',
);
setControlValue(
container.querySelector<HTMLTextAreaElement>('[data-testid="action-body"]'),
'{"name":"{{components.nameInput.value}}"}',
);
click(container.querySelector('button[aria-label="Add query parameters row"]'));
setControlValue(
container.querySelector<HTMLInputElement>('input[aria-label="Query parameters key"]'),
'limit',
);
setControlValue(
container.querySelector<HTMLInputElement>('input[aria-label="Query parameters value"]'),
'25',
);
doc = currentDocument(container);
expect(doc.project.actions[0]).toMatchObject({
name: 'Create inventory item',
method: 'POST',
url: 'https://api.example.com/items',
queryParameters: { limit: '25' },
bodyTemplate: '{"name":"{{components.nameInput.value}}"}',
authenticationType: 'anonymous',
});
});
test('keeps invalid drafts local until they are corrected', () => {
click(container.querySelector('[data-testid="add-rest-action"]'));
const addButton = container.querySelector<HTMLButtonElement>(
'[data-testid="add-rest-action"]',
);
expect(addButton?.disabled).toBe(true);
const nameInput = container.querySelector<HTMLInputElement>(
'[data-testid="action-name"]',
);
setControlValue(nameInput, '');
expect(currentDocument(container).project.actions[0].name)
.toBe('New REST Action');
expect(doneButton(container).disabled).toBe(true);
setControlValue(nameInput, 'Valid action name');
expect(doneButton(container).disabled).toBe(false);
const urlInput = container.querySelector<HTMLInputElement>(
'[data-testid="action-url"]',
);
setControlValue(urlInput, 'https://');
expect(currentDocument(container).project.actions[0].url)
.toBe('https://api.example.com');
expect(doneButton(container).disabled).toBe(true);
setControlValue(urlInput, 'https://api.example.com/items');
expect(doneButton(container).disabled).toBe(false);
click(container.querySelector(
'button[aria-label="Add query parameters row"]',
));
expect(currentDocument(container).project.actions[0].queryParameters)
.toEqual({});
expect(doneButton(container).disabled).toBe(true);
setControlValue(
container.querySelector<HTMLInputElement>(
'input[aria-label="Query parameters key"]',
),
'limit',
);
expect(currentDocument(container).project.actions[0].queryParameters)
.toEqual({ limit: '' });
expect(doneButton(container).disabled).toBe(false);
});
test('duplicates an action with a fresh ID and opens the copy for editing', () => {
click(container.querySelector('[data-testid="add-rest-action"]'));
setControlValue(
container.querySelector<HTMLInputElement>('[data-testid="action-name"]'),
'Fetch inventory',
);
click(doneButton(container));
click(container.querySelector(
'button[aria-label="Duplicate REST action Fetch inventory"]',
));
const doc = currentDocument(container);
expect(doc.project.actions.map((action) => action.id)).toEqual([
'action_rest_1',
'action_rest_2',
]);
expect(doc.project.actions[1].name).toBe('Fetch inventory Copy');
expect(container.querySelector(
'[data-testid="rest-action-editor-action_rest_2"]',
)).not.toBeNull();
});
test('requires confirmation and removes only the selected action', () => {
click(container.querySelector('[data-testid="add-rest-action"]'));
click(doneButton(container));
const confirm = jest.spyOn(window, 'confirm')
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const deleteButton = () => container.querySelector(
'button[aria-label="Delete REST action New REST Action"]',
);
click(deleteButton());
expect(currentDocument(container).project.actions).toHaveLength(1);
click(deleteButton());
expect(currentDocument(container).project.actions).toHaveLength(0);
expect(confirm).toHaveBeenCalledWith(
'Delete REST action "New REST Action"? This cannot be undone.',
);
});
});

View File

@ -1,8 +1,8 @@
/**
* Action and Binding Inspector Step 15.5 / 15.6 / 16.5 / 17.1 / 17.3 / 17.4 / 17.5 / 18.1
*
* Read-only view of REST actions and bindings from the current project,
* read directly from ProjectContext.
* Visual REST action authoring plus binding inspection for the current project,
* synchronized through the canonical document in ProjectContext.
*
* Diagnostics computed
* Per action (Step 15.6):
@ -35,7 +35,7 @@
* Binding targeting Table.rows / Table.value (warning)
*/
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useProject } from '../../context/ProjectContext';
import { executeAction } from '../../api/proxyApi';
import type { ProxyResponse } from '../../api/proxyApi';
@ -49,6 +49,18 @@ import {
parseVariableTargetPath,
} from '../Preview/bindingUtils';
import styles from './ActionInspector.module.css';
import RestActionEditor from './RestActionEditor';
import {
appendRestAction,
buildDeleteConfirmation,
collectReferencedActionIds,
createRestAction,
duplicateRestAction,
findActionReferences,
removeRestActionAt,
replaceRestActionAt,
validateRestAction,
} from './actionEditorUtils';
// ── Diagnostic types ──────────────────────────────────────────────────────────
@ -60,20 +72,24 @@ type Diagnostic = {
};
/**
* All diagnostics indexed by their subject ID so cards can look up their own.
* Keys: action IDs, binding IDs.
* All diagnostics indexed by a stable render subject so cards can look up their own.
* Keys: action indices (isolating duplicate IDs), binding IDs, and synthetic subjects.
* Also a special key "__componentEvents" for component-event issues not tied to
* a specific binding (currently unused shown on binding cards that reference
* the missing action).
*/
type DiagMap = Record<string, Diagnostic[]>;
function actionDiagnosticKey(actionIndex: number): string {
return `__action_${actionIndex}`;
}
// ── Pure diagnostic computation ───────────────────────────────────────────────
/**
* Computes all diagnostics from the project in a single pass.
* Never throws any unexpected input is handled gracefully.
* Returns a DiagMap keyed by actionId or bindingId.
* Returns a DiagMap keyed by action index, binding ID, or synthetic subject.
*/
function computeDiagnostics(
actions: RestAction[],
@ -115,11 +131,16 @@ function computeDiagnostics(
// ── 1. Diagnostics per action ─────────────────────────────────────────────
for (const action of actions) {
actions.forEach((action, actionIndex) => {
const diagnosticKey = actionDiagnosticKey(actionIndex);
for (const issue of validateRestAction(action, actions)) {
add(diagnosticKey, issue.severity, issue.message);
}
// 1a. Untriggered action
if (!triggeredActionIds.has(action.id)) {
add(
action.id,
diagnosticKey,
'info',
`Action "${action.name}" is not triggered by any component event. ` +
`Add an events entry on a component: { "event": "onClick", "actionId": "${action.id}" }`,
@ -136,7 +157,7 @@ function computeDiagnostics(
// runtime does not interpolate pathParameters values at all.
if (token.location.startsWith('pathParameters.')) {
add(
action.id,
diagnosticKey,
'warn',
`Template "${token.raw}" at ${token.location} uses a variable placeholder ` +
`in a path-parameter value. Variable interpolation is not supported in ` +
@ -147,7 +168,7 @@ function computeDiagnostics(
// but use a separate, clearly scoped message.
if (!declaredVariableNames.has(token.name)) {
add(
action.id,
diagnosticKey,
'warn',
`Template "${token.raw}" at ${token.location} also references variable ` +
`"${token.name}" which is not declared in project.variables.`,
@ -159,7 +180,7 @@ function computeDiagnostics(
// Variable token in a supported location — check declaration (Step 18.1)
if (!declaredVariableNames.has(token.name)) {
add(
action.id,
diagnosticKey,
'warn',
`Template "${token.raw}" at ${token.location} references variable ` +
`"${token.name}" which is not declared in project.variables.`,
@ -171,7 +192,7 @@ function computeDiagnostics(
// Component token — check property and existence
if (token.propertyName !== 'value') {
add(
action.id,
diagnosticKey,
'warn',
`Template "${token.raw}" at ${token.location} references property ` +
`"${token.propertyName ?? '?'}". Only "value" is supported. ` +
@ -182,7 +203,7 @@ function computeDiagnostics(
// Missing component
if (token.componentName && !componentsByName.has(token.componentName)) {
add(
action.id,
diagnosticKey,
'warn',
`Template "${token.raw}" at ${token.location} references component ` +
`"${token.componentName}" which does not exist on any page.`,
@ -193,7 +214,7 @@ function computeDiagnostics(
for (const bad of malformed) {
const isMalformedVar = classifyVariableExpression(bad.raw) === 'malformed';
add(
action.id,
diagnosticKey,
'warn',
isMalformedVar
? `Malformed variable template "${bad.raw}" at ${bad.location}: ` +
@ -203,7 +224,7 @@ function computeDiagnostics(
`missing closing "}}". Check your template syntax.`,
);
}
}
});
// ── Index: component name → all components with that name (duplicate check) ─
const componentsByNameAll = new Map<string, CanvasComponent[]>();
@ -568,17 +589,43 @@ type ActionCardProps = {
action: RestAction;
diags: Diagnostic[];
allComponents: CanvasComponent[];
isEditing: boolean;
actionsLocked: boolean;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
};
function ActionCard({ action, diags, allComponents }: ActionCardProps): React.ReactElement {
function ActionCard({
action,
diags,
allComponents,
isEditing,
actionsLocked,
onEdit,
onDuplicate,
onDelete,
}: ActionCardProps): React.ReactElement {
const [test, setTest] = useState<TestState>({ status: 'idle' });
const requestGeneration = useRef(0);
useEffect(() => {
requestGeneration.current += 1;
setTest({ status: 'idle' });
return () => {
requestGeneration.current += 1;
};
}, [action]);
const handleTest = useCallback(async () => {
const generation = ++requestGeneration.current;
setTest({ status: 'running' });
try {
const response = await executeAction(action);
if (generation !== requestGeneration.current) return;
setTest({ status: 'ok', response });
} catch (err) {
if (generation !== requestGeneration.current) return;
setTest({
status: 'error',
message: err instanceof Error ? err.message : String(err),
@ -586,6 +633,8 @@ function ActionCard({ action, diags, allComponents }: ActionCardProps): React.Re
}
}, [action]);
const controlsLocked = actionsLocked || test.status === 'running';
// Compute template info for display
const { tokens, malformed } = useMemo(
() => extractTemplates(action),
@ -662,11 +711,41 @@ function ActionCard({ action, diags, allComponents }: ActionCardProps): React.Re
{/* ── Diagnostics ── */}
<DiagList diags={diags} />
<div className={styles.actionControls}>
<button
type="button"
className={styles.smallButton}
onClick={onEdit}
disabled={controlsLocked}
aria-label={`Edit REST action ${action.name}`}
>
{isEditing ? 'Editing' : 'Edit'}
</button>
<button
type="button"
className={styles.smallButton}
onClick={onDuplicate}
disabled={controlsLocked}
aria-label={`Duplicate REST action ${action.name}`}
>
Duplicate
</button>
<button
type="button"
className={styles.dangerButton}
onClick={onDelete}
disabled={controlsLocked}
aria-label={`Delete REST action ${action.name}`}
>
Delete
</button>
</div>
{/* ── Test Action ── */}
<div className={styles.testRow}>
<button
className={styles.testBtn}
disabled={test.status === 'running'}
disabled={test.status === 'running' || actionsLocked}
onClick={handleTest}
>
{test.status === 'running' ? 'Running…' : 'Test Action'}
@ -793,7 +872,7 @@ function BindingCard({
// ── Main component ────────────────────────────────────────────────────────────
function ActionInspector(): React.ReactElement {
const { doc } = useProject();
const { doc, setDoc } = useProject();
const { actions, bindings, pages, variables } = doc.project;
const allComponents = useMemo(
@ -801,6 +880,60 @@ function ActionInspector(): React.ReactElement {
[pages],
);
const [editingActionIndex, setEditingActionIndex] = useState<number | null>(null);
useEffect(() => {
if (
editingActionIndex !== null &&
editingActionIndex >= actions.length
) {
setEditingActionIndex(null);
}
}, [actions.length, editingActionIndex]);
const handleAddAction = useCallback(() => {
const action = createRestAction(
actions,
collectReferencedActionIds(doc),
);
setDoc((current) => appendRestAction(current, action));
setEditingActionIndex(actions.length);
}, [actions, doc, setDoc]);
const handleUpdateAction = useCallback(
(actionIndex: number, action: RestAction) => {
setDoc((current) => replaceRestActionAt(current, actionIndex, action));
},
[setDoc],
);
const handleDuplicateAction = useCallback(
(source: RestAction) => {
const duplicate = duplicateRestAction(
source,
actions,
collectReferencedActionIds(doc),
);
setDoc((current) => appendRestAction(current, duplicate));
setEditingActionIndex(actions.length);
},
[actions, doc, setDoc],
);
const handleDeleteAction = useCallback(
(actionIndex: number, action: RestAction) => {
const references = findActionReferences(doc, action.id);
if (!window.confirm(buildDeleteConfirmation(action, references))) return;
setDoc((current) => removeRestActionAt(current, actionIndex));
setEditingActionIndex((current) => {
if (current === null || current === actionIndex) return null;
return current > actionIndex ? current - 1 : current;
});
},
[doc, setDoc],
);
// Compute all diagnostics once per render cycle
const diagMap = useMemo(
() => computeDiagnostics(actions, bindings, allComponents, variables),
@ -809,11 +942,17 @@ function ActionInspector(): React.ReactElement {
// Count total warnings/infos for section summary banners
const actionWarnings = actions.reduce(
(n, a) => n + (diagMap[a.id]?.filter((d) => d.severity === 'warn').length ?? 0),
(count, _action, actionIndex) => count + (
diagMap[actionDiagnosticKey(actionIndex)]
?.filter((diagnostic) => diagnostic.severity === 'warn').length ?? 0
),
0,
);
const actionInfos = actions.reduce(
(n, a) => n + (diagMap[a.id]?.filter((d) => d.severity === 'info').length ?? 0),
(count, _action, actionIndex) => count + (
diagMap[actionDiagnosticKey(actionIndex)]
?.filter((diagnostic) => diagnostic.severity === 'info').length ?? 0
),
0,
);
const bindingWarnings = bindings.reduce(
@ -828,10 +967,11 @@ function ActionInspector(): React.ReactElement {
<div className={styles.pageHeader}>
<div className={styles.pageTitle}>Actions &amp; Bindings</div>
<div className={styles.pageSubtitle}>
Read-only inspector for the current project&apos;s REST actions and
bindings. Use the JSON Editor to modify definitions.
The <strong>Test Action</strong> button calls the backend proxy
directly. Diagnostics highlight broken references and missing wiring.
Create and configure anonymous REST actions here without editing
project JSON by hand. Valid edits synchronize with the canonical
document immediately. <strong>Test Action</strong> calls the backend
proxy with the configured action; diagnostics highlight invalid
definitions and broken references.
</div>
</div>
@ -839,7 +979,21 @@ function ActionInspector(): React.ReactElement {
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>REST Actions</span>
<div className={styles.sectionHeaderMeta}>
<span className={styles.sectionCount}>{actions.length}</span>
<button
type="button"
className={styles.primaryButton}
data-testid="add-rest-action"
onClick={handleAddAction}
disabled={editingActionIndex !== null}
title={editingActionIndex !== null
? 'Finish the current action edit before adding another.'
: undefined}
>
+ Add REST action
</button>
</div>
</div>
{/* Summary banner */}
@ -856,17 +1010,34 @@ function ActionInspector(): React.ReactElement {
{actions.length === 0 ? (
<div className={styles.empty}>
No REST actions defined. Add actions to{' '}
<code>project.actions</code> in the JSON Editor.
No REST actions defined. Add one to begin configuring the canonical
project document visually.
</div>
) : (
actions.map((action) => (
actions.map((action, actionIndex) => (
<React.Fragment key={`${action.id}-${actionIndex}`}>
<ActionCard
key={action.id}
action={action}
diags={diagMap[action.id] ?? []}
diags={diagMap[actionDiagnosticKey(actionIndex)] ?? []}
allComponents={allComponents}
isEditing={editingActionIndex === actionIndex}
actionsLocked={editingActionIndex !== null}
onEdit={() => setEditingActionIndex(actionIndex)}
onDuplicate={() => handleDuplicateAction(action)}
onDelete={() => handleDeleteAction(actionIndex, action)}
/>
{editingActionIndex === actionIndex && (
<RestActionEditor
key={`${action.id}-${actionIndex}`}
action={action}
actions={actions}
onChange={(updatedAction) =>
handleUpdateAction(actionIndex, updatedAction)
}
onDone={() => setEditingActionIndex(null)}
/>
)}
</React.Fragment>
))
)}
</div>

View File

@ -0,0 +1,479 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
AuthenticationType,
HttpMethod,
RestAction,
} from '../../types/project';
import {
HTTP_METHODS,
isAbsoluteHttpUrl,
keyValueRowsToRecord,
validateKeyValueRows,
validateRestAction,
} from './actionEditorUtils';
import type {
KeyValueRow,
RequestMapKeyKind,
} from './actionEditorUtils';
import styles from './ActionInspector.module.css';
const AUTHENTICATION_OPTIONS: Array<{
value: AuthenticationType;
label: string;
}> = [
{ value: 'anonymous', label: 'Anonymous' },
{ value: 'bearerToken', label: 'Bearer token (Slice 3)' },
{ value: 'basicAuth', label: 'Basic authentication (Slice 3)' },
{ value: 'apiKeyHeader', label: 'API key header (Slice 3)' },
{ value: 'apiKeyQueryParameter', label: 'API key query parameter (Slice 3)' },
];
type KeyValueEditorProps = {
fieldName: string;
title: string;
keyPlaceholder: string;
valuePlaceholder?: string;
keyKind?: RequestMapKeyKind;
value: Record<string, string>;
onChange: (value: Record<string, string>) => void;
onValidityChange: (valid: boolean) => void;
};
function makeRows(value: Record<string, string>): KeyValueRow[] {
return Object.entries(value).map(([key, entryValue], index) => ({
id: index + 1,
key,
value: entryValue,
}));
}
function KeyValueEditor({
fieldName,
title,
keyPlaceholder,
valuePlaceholder = 'Value or {{components.name.value}}',
keyKind = 'generic',
value,
onChange,
onValidityChange,
}: KeyValueEditorProps): React.ReactElement {
const [rows, setRows] = useState<KeyValueRow[]>(() => makeRows(value));
const [error, setError] = useState<string | null>(null);
const nextRowId = useRef(rows.length + 1);
const lastCanonicalValue = useRef(value);
useEffect(() => {
if (value === lastCanonicalValue.current) return;
const nextRows = makeRows(value);
lastCanonicalValue.current = value;
nextRowId.current = nextRows.length + 1;
setRows(nextRows);
setError(null);
onValidityChange(true);
}, [onValidityChange, value]);
const commitRows = useCallback(
(nextRows: KeyValueRow[]) => {
setRows(nextRows);
const validationError = validateKeyValueRows(nextRows, keyKind);
setError(validationError);
onValidityChange(validationError === null);
if (validationError) return;
const nextRecord = keyValueRowsToRecord(nextRows);
lastCanonicalValue.current = nextRecord;
onChange(nextRecord);
},
[keyKind, onChange, onValidityChange],
);
const handleAdd = useCallback(() => {
setRows((current) => [
...current,
{ id: nextRowId.current++, key: '', value: '' },
]);
setError('Every row needs a key before it can be added to the project document.');
onValidityChange(false);
}, [onValidityChange]);
const handleChange = useCallback(
(rowId: number, field: 'key' | 'value', nextValue: string) => {
commitRows(
rows.map((row) =>
row.id === rowId ? { ...row, [field]: nextValue } : row,
),
);
},
[commitRows, rows],
);
const handleRemove = useCallback(
(rowId: number) => {
commitRows(rows.filter((row) => row.id !== rowId));
},
[commitRows, rows],
);
return (
<div
className={styles.keyValueEditor}
data-testid={`action-${fieldName}`}
>
<div className={styles.keyValueHeader}>
<span className={styles.formLabel}>{title}</span>
<button
type="button"
className={styles.smallButton}
onClick={handleAdd}
aria-label={`Add ${title.toLowerCase()} row`}
>
+ Add row
</button>
</div>
{rows.length === 0 && (
<div className={styles.formHint}>No entries configured.</div>
)}
{rows.map((row) => (
<div className={styles.keyValueRow} key={row.id}>
<input
className={styles.formInput}
aria-label={`${title} key`}
placeholder={keyPlaceholder}
value={row.key}
onChange={(event) => handleChange(row.id, 'key', event.target.value)}
/>
<input
className={styles.formInput}
aria-label={`${title} value`}
placeholder={valuePlaceholder}
value={row.value}
onChange={(event) => handleChange(row.id, 'value', event.target.value)}
/>
<button
type="button"
className={styles.removeRowButton}
onClick={() => handleRemove(row.id)}
aria-label={`Remove ${title.toLowerCase()} row`}
>
Remove
</button>
</div>
))}
{error && (
<div className={styles.inlineError} role="alert">
{error} Invalid rows remain local until corrected.
</div>
)}
</div>
);
}
export type RestActionEditorProps = {
action: RestAction;
actions: RestAction[];
onChange: (action: RestAction) => void;
onDone: () => void;
};
function RestActionEditor({
action,
actions,
onChange,
onDone,
}: RestActionEditorProps): React.ReactElement {
const [nameDraft, setNameDraft] = useState(action.name);
const [urlDraft, setUrlDraft] = useState(action.url);
const [invalidMapFields, setInvalidMapFields] = useState<Set<string>>(() => new Set());
const lastEmittedAction = useRef<RestAction | null>(null);
const nameDraftValid = nameDraft.trim().length > 0;
const urlDraftValid = isAbsoluteHttpUrl(urlDraft);
const hasInvalidDrafts =
!nameDraftValid || !urlDraftValid || invalidMapFields.size > 0;
const issues = useMemo(
() => validateRestAction(action, actions),
[action, actions],
);
useEffect(() => {
if (action === lastEmittedAction.current) {
lastEmittedAction.current = null;
return;
}
setNameDraft(action.name);
setUrlDraft(action.url);
setInvalidMapFields(new Set());
}, [action]);
const update = useCallback(
<K extends keyof RestAction>(field: K, value: RestAction[K]) => {
const nextAction = { ...action, [field]: value };
lastEmittedAction.current = nextAction;
onChange(nextAction);
},
[action, onChange],
);
const handleNameDraftChange = useCallback(
(value: string) => {
setNameDraft(value);
if (value.trim()) update('name', value);
},
[update],
);
const handleUrlDraftChange = useCallback(
(value: string) => {
setUrlDraft(value);
if (isAbsoluteHttpUrl(value)) update('url', value);
},
[update],
);
const handleMapValidityChange = useCallback(
(fieldName: string, valid: boolean) => {
setInvalidMapFields((current) => {
const alreadyInvalid = current.has(fieldName);
if ((valid && !alreadyInvalid) || (!valid && alreadyInvalid)) {
return current;
}
const next = new Set(current);
if (valid) next.delete(fieldName);
else next.add(fieldName);
return next;
});
},
[],
);
const editorId = `rest-action-editor-${action.id}`;
return (
<form
className={styles.actionEditor}
aria-label={`Edit REST action ${action.name}`}
data-testid={editorId}
onSubmit={(event) => {
event.preventDefault();
if (hasInvalidDrafts) return;
onDone();
}}
>
<div className={styles.actionEditorHeader}>
<div>
<div className={styles.actionEditorTitle}>Edit REST action</div>
<div className={styles.formHint}>
Valid edits update the canonical project document immediately.
</div>
</div>
<button
type="submit"
className={styles.primaryButton}
disabled={hasInvalidDrafts}
title={hasInvalidDrafts ? 'Resolve invalid local drafts before closing.' : undefined}
>
Done
</button>
</div>
{issues.length > 0 && (
<div className={styles.editorIssues} aria-label="Action validation">
{issues.map((issue, index) => (
<div
key={`${issue.field}-${index}`}
className={
issue.severity === 'warn'
? styles.editorIssueWarn
: styles.editorIssueInfo
}
>
{issue.message}
</div>
))}
</div>
)}
<div className={styles.formGrid}>
<label className={styles.formField}>
<span className={styles.formLabel}>Action ID</span>
<input
className={styles.formInput}
data-testid="action-id"
value={action.id}
readOnly
/>
<span className={styles.formHint}>
Stable because component events and bindings reference this ID.
</span>
</label>
<label className={styles.formField}>
<span className={styles.formLabel}>Name</span>
<input
className={styles.formInput}
data-testid="action-name"
value={nameDraft}
onChange={(event) => handleNameDraftChange(event.target.value)}
/>
{!nameDraftValid && (
<span className={styles.inlineError} role="alert">
Name is required. The last valid canonical value is preserved.
</span>
)}
</label>
</div>
<label className={styles.formField}>
<span className={styles.formLabel}>Description</span>
<textarea
className={styles.formTextarea}
data-testid="action-description"
value={action.description ?? ''}
onChange={(event) => update('description', event.target.value)}
rows={2}
/>
</label>
<div className={styles.endpointRow}>
<label className={styles.methodField}>
<span className={styles.formLabel}>HTTP method</span>
<select
className={styles.formSelect}
data-testid="action-method"
value={action.method}
onChange={(event) =>
update('method', event.target.value as HttpMethod)
}
>
{HTTP_METHODS.map((method) => (
<option key={method} value={method}>{method}</option>
))}
</select>
</label>
<label className={styles.urlField}>
<span className={styles.formLabel}>Endpoint URL</span>
<input
className={styles.formInput}
data-testid="action-url"
value={urlDraft}
placeholder="https://api.example.com/items/{{itemId}}"
onChange={(event) => handleUrlDraftChange(event.target.value)}
/>
{!urlDraftValid && (
<span className={styles.inlineError} role="alert">
Enter absolute HTTP or HTTPS without spaces or embedded credentials.
</span>
)}
</label>
</div>
<label className={styles.formField}>
<span className={styles.formLabel}>Authentication</span>
<select
className={styles.formSelect}
data-testid="action-authentication"
value={action.authenticationType}
onChange={(event) => {
const nextAuthenticationType =
event.target.value as AuthenticationType;
if (nextAuthenticationType === 'anonymous') {
update('authenticationType', nextAuthenticationType);
} else {
event.currentTarget.value = action.authenticationType;
}
}}
>
{AUTHENTICATION_OPTIONS.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.value !== 'anonymous'}
>
{option.label}
</option>
))}
</select>
<span className={styles.formHint}>
Slice 2 creates anonymous actions only. Authentication and secrets are
configured in Slice 3.
</span>
</label>
<details className={styles.actionEditorDetails} open>
<summary>Request parameters</summary>
<div className={styles.detailsBody}>
<KeyValueEditor
key={`${action.id}-headers`}
fieldName="headers"
title="Headers"
keyPlaceholder="Accept"
keyKind="header"
value={action.headers ?? {}}
onChange={(nextValue) => update('headers', nextValue)}
onValidityChange={(valid) =>
handleMapValidityChange('headers', valid)
}
/>
<KeyValueEditor
key={`${action.id}-query`}
fieldName="query-parameters"
title="Query parameters"
keyPlaceholder="limit"
value={action.queryParameters ?? {}}
onChange={(nextValue) => update('queryParameters', nextValue)}
onValidityChange={(valid) =>
handleMapValidityChange('queryParameters', valid)
}
/>
<KeyValueEditor
key={`${action.id}-path`}
fieldName="path-parameters"
title="Path parameters"
keyPlaceholder="itemId"
keyKind="path"
valuePlaceholder="Static replacement value (templates are not supported)"
value={action.pathParameters ?? {}}
onChange={(nextValue) => update('pathParameters', nextValue)}
onValidityChange={(valid) =>
handleMapValidityChange('pathParameters', valid)
}
/>
</div>
</details>
<details className={styles.actionEditorDetails} open>
<summary>Request body</summary>
<div className={styles.detailsBody}>
<label className={styles.formField}>
<span className={styles.formLabel}>Body template</span>
<textarea
className={[styles.formTextarea, styles.bodyTemplate].join(' ')}
data-testid="action-body"
value={action.bodyTemplate ?? ''}
placeholder={'{"name":"{{components.nameInput.value}}"}'}
onChange={(event) => update('bodyTemplate', event.target.value)}
rows={7}
spellCheck={false}
/>
{(action.method === 'GET' || action.method === 'DELETE') && (
<span className={styles.formHint}>
{action.method} requests retain this configuration but do not
send a request body.
</span>
)}
</label>
</div>
</details>
</form>
);
}
export default RestActionEditor;

View File

@ -0,0 +1,359 @@
import type { ProjectDocument, RestAction } from '../../types/project';
import {
appendRestAction,
buildDeleteConfirmation,
collectReferencedActionIds,
createRestAction,
duplicateRestAction,
findActionReferences,
isAbsoluteHttpUrl,
keyValueRowsToRecord,
nextRestActionId,
removeRestActionAt,
replaceRestActionAt,
validateKeyValueRows,
validateRestAction,
} from './actionEditorUtils';
function makeAction(overrides: Partial<RestAction> = {}): RestAction {
return {
id: 'action_existing',
name: 'Existing action',
method: 'GET',
url: 'https://api.example.com/items',
authenticationType: 'anonymous',
...overrides,
};
}
function makeDocument(actions: RestAction[] = []): ProjectDocument {
return {
schemaVersion: '0.1.0',
project: {
id: 'project_test',
name: 'Action editor test',
pages: [
{
id: 'page_main',
name: 'Main',
components: [],
},
],
actions,
bindings: [],
variables: {},
settings: {},
},
};
}
describe('REST action canonical document operations', () => {
test('generates the first available stable action ID', () => {
const actions = [
makeAction({ id: 'action_rest_1' }),
makeAction({ id: 'action_rest_3' }),
];
expect(nextRestActionId(actions)).toBe('action_rest_2');
expect(nextRestActionId(actions, ['action_rest_2'])).toBe('action_rest_4');
expect(createRestAction(actions, ['action_rest_2']).id).toBe('action_rest_4');
});
test('creates a schema-shaped anonymous action without legacy responseMapping', () => {
const action = createRestAction([]);
expect(action).toEqual({
id: 'action_rest_1',
name: 'New REST Action',
description: '',
method: 'GET',
url: 'https://api.example.com',
headers: { Accept: 'application/json' },
queryParameters: {},
pathParameters: {},
bodyTemplate: '',
authenticationType: 'anonymous',
});
expect(action).not.toHaveProperty('responseMapping');
});
test('duplicates request configuration with a new ID and independent maps', () => {
const source = makeAction({
id: 'action_rest_1',
name: 'Fetch inventory',
headers: {
Accept: 'application/json',
Authorization: 'Bearer must-not-copy',
'X-Api-Key': 'must-not-copy',
},
queryParameters: { limit: '10' },
pathParameters: { itemId: '42' },
authenticationType: 'bearerToken',
responseMapping: [{ source: 'body', target: 'components.viewer.value' }],
});
const duplicate = duplicateRestAction(source, [source]);
expect(duplicate.id).toBe('action_rest_2');
expect(duplicate.name).toBe('Fetch inventory Copy');
expect(duplicate.headers).toEqual({ Accept: 'application/json' });
expect(duplicate.headers).not.toBe(source.headers);
expect(duplicate.queryParameters).not.toBe(source.queryParameters);
expect(duplicate.pathParameters).not.toBe(source.pathParameters);
expect(duplicate.authenticationType).toBe('anonymous');
expect(duplicate).not.toHaveProperty('responseMapping');
});
test('appends an action immutably', () => {
const original = makeDocument();
const action = createRestAction([]);
const updated = appendRestAction(original, action);
expect(updated).not.toBe(original);
expect(updated.project).not.toBe(original.project);
expect(updated.project.actions).toEqual([action]);
expect(original.project.actions).toEqual([]);
});
test('replaces only the selected action index immutably', () => {
const first = makeAction({ id: 'first' });
const second = makeAction({ id: 'second' });
const original = makeDocument([first, second]);
const replacement = { ...first, name: 'Updated' };
const updated = replaceRestActionAt(original, 0, replacement);
expect(updated.project.actions).toEqual([replacement, second]);
expect(updated.project.actions[1]).toBe(second);
expect(original.project.actions[0].name).toBe('Existing action');
});
test('returns the same document when replace or remove index is out of range', () => {
const original = makeDocument([makeAction()]);
expect(replaceRestActionAt(original, 2, makeAction({ id: 'missing' }))).toBe(original);
expect(removeRestActionAt(original, -1)).toBe(original);
});
test('removes the action without silently cascading references', () => {
const action = makeAction();
const original = makeDocument([action]);
original.project.bindings = [
{
id: 'binding_result',
source: 'actions.action_existing.response.body',
target: 'components.viewer.value',
trigger: 'onSuccess',
},
];
const updated = removeRestActionAt(original, 0);
expect(updated.project.actions).toEqual([]);
expect(updated.project.bindings).toEqual(original.project.bindings);
});
test('uses the selected index when duplicate IDs make references ambiguous', () => {
const first = makeAction({ id: 'duplicate', name: 'First' });
const second = makeAction({ id: 'duplicate', name: 'Second' });
const original = makeDocument([first, second]);
const replacement = { ...second, name: 'Updated second' };
const replaced = replaceRestActionAt(original, 1, replacement);
expect(replaced.project.actions).toEqual([first, replacement]);
const removed = removeRestActionAt(replaced, 1);
expect(removed.project.actions).toEqual([first]);
});
});
describe('REST action reference discovery', () => {
test('finds component events, page events, and nested response bindings', () => {
const action = makeAction();
const doc = makeDocument([action]);
doc.project.pages[0].events = [
{ event: 'onLoad', actionId: action.id },
];
doc.project.pages[0].components = [
{
id: 'button_run',
name: 'runButton',
type: 'Button',
position: { x: 0, y: 0 },
size: { width: 100, height: 40 },
properties: {},
events: [{ event: 'onClick', actionId: action.id }],
},
];
doc.project.bindings = [
{
id: 'binding_result',
source: `actions.${action.id}.response.body.items`,
target: 'components.viewer.value',
trigger: 'onSuccess',
},
{
id: 'binding_other',
source: 'actions.other.response',
target: 'components.viewer.value',
trigger: 'onSuccess',
},
];
expect(findActionReferences(doc, action.id)).toEqual([
{ kind: 'pageEvent', label: 'Page "Main" onLoad' },
{ kind: 'componentEvent', label: 'Component "runButton" onClick' },
{ kind: 'binding', label: 'Binding "binding_result"' },
]);
expect(collectReferencedActionIds(doc)).toEqual([
action.id,
'other',
]);
});
test('builds a detailed warning for referenced deletion', () => {
const action = makeAction({ name: 'Fetch data' });
const references = [
{ kind: 'componentEvent' as const, label: 'Component "run" onClick' },
{ kind: 'binding' as const, label: 'Binding "result"' },
];
const message = buildDeleteConfirmation(action, references);
expect(message).toContain('referenced by 2 configuration items');
expect(message).toContain('- Component "run" onClick');
expect(message).toContain('- Binding "result"');
expect(message).toContain('may leave dangling or ambiguous references');
});
test('builds a simple confirmation for an unreferenced action', () => {
expect(buildDeleteConfirmation(makeAction(), []))
.toBe('Delete REST action "Existing action"? This cannot be undone.');
});
});
describe('REST action validation', () => {
test('accepts only absolute HTTP and HTTPS endpoint URLs', () => {
expect(isAbsoluteHttpUrl('https://api.example.com/items/{{itemId}}')).toBe(true);
expect(isAbsoluteHttpUrl('http://localhost:3001/test')).toBe(true);
expect(isAbsoluteHttpUrl('https://')).toBe(false);
expect(isAbsoluteHttpUrl('ftp://api.example.com/items')).toBe(false);
expect(isAbsoluteHttpUrl('https://user:secret@api.example.com/items')).toBe(false);
expect(isAbsoluteHttpUrl('https://api.example.com/with space')).toBe(false);
});
test('reports required fields, ID grammar, URL shape, and duplicate IDs', () => {
const action = makeAction({
id: 'invalid.action',
name: ' ',
url: 'example.com with spaces',
});
const issues = validateRestAction(action, [action, { ...action }]);
expect(issues.map((issue) => issue.field)).toEqual(
expect.arrayContaining(['id', 'name', 'url']),
);
expect(issues.some((issue) => issue.message.includes('duplicated'))).toBe(true);
});
test('reports method/body and deferred authentication behavior without mutation', () => {
const action = makeAction({
method: 'GET',
bodyTemplate: '{"test":true}',
authenticationType: 'bearerToken',
});
const issues = validateRestAction(action, [action]);
expect(issues).toEqual(expect.arrayContaining([
expect.objectContaining({
field: 'bodyTemplate',
severity: 'info',
}),
expect.objectContaining({
field: 'authenticationType',
severity: 'info',
}),
]));
expect(action.authenticationType).toBe('bearerToken');
});
test('warns when a path-parameter replacement uses a template', () => {
const action = makeAction({
pathParameters: { itemId: '{{components.itemId.value}}' },
});
expect(validateRestAction(action, [action])).toContainEqual(
expect.objectContaining({
field: 'Path parameters',
severity: 'warn',
}),
);
});
test('warns about invalid header names already present in canonical JSON', () => {
const action = makeAction({
headers: { 'Bad Header': 'value' },
});
expect(validateRestAction(action, [action])).toContainEqual(
expect.objectContaining({
field: 'Headers',
severity: 'warn',
}),
);
});
test('accepts a complete anonymous action', () => {
const action = makeAction({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
queryParameters: { verbose: 'true' },
pathParameters: { itemId: '42' },
bodyTemplate: '{"name":"test"}',
});
expect(validateRestAction(action, [action])).toEqual([]);
});
});
describe('key/value request-map drafts', () => {
test('requires non-empty, unique, and safe keys', () => {
expect(validateKeyValueRows([{ id: 1, key: '', value: '' }]))
.toContain('needs a key');
expect(validateKeyValueRows([
{ id: 1, key: 'Accept', value: 'application/json' },
{ id: 2, key: 'Accept', value: 'text/plain' },
])).toBe('Duplicate key "Accept".');
expect(validateKeyValueRows([
{ id: 1, key: ' Accept ', value: 'application/json' },
])).toContain('whitespace');
expect(validateKeyValueRows([
{ id: 1, key: 'Bad Header', value: 'value' },
], 'header')).toContain('HTTP token');
expect(validateKeyValueRows([
{ id: 1, key: 'Authorization', value: 'Bearer secret' },
], 'header')).toContain('secure authentication');
expect(validateKeyValueRows([
{ id: 1, key: 'item-id', value: '42' },
], 'path')).toContain('letters, numbers, and underscores');
expect(validateKeyValueRows([
{ id: 1, key: 'Accept', value: 'application/json' },
{ id: 2, key: 'accept', value: 'text/plain' },
], 'header')).toBe('Duplicate key "accept".');
});
test('converts valid rows to a canonical string map', () => {
const rows = [
{ id: 1, key: 'Accept', value: 'application/json' },
{ id: 2, key: 'X-Trace', value: '{{variables.traceId}}' },
];
expect(validateKeyValueRows(rows, 'header')).toBeNull();
expect(keyValueRowsToRecord(rows)).toEqual({
Accept: 'application/json',
'X-Trace': '{{variables.traceId}}',
});
});
});

View File

@ -0,0 +1,429 @@
import type {
HttpMethod,
ProjectDocument,
RestAction,
} from '../../types/project';
export const HTTP_METHODS: readonly HttpMethod[] = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
];
export type ActionEditorIssue = {
field: string;
severity: 'warn' | 'info';
message: string;
};
export type ActionReference = {
kind: 'componentEvent' | 'pageEvent' | 'binding';
label: string;
};
export type KeyValueRow = {
id: number;
key: string;
value: string;
};
const ACTION_ID_PREFIX = 'action_rest_';
export function nextRestActionId(
actions: RestAction[],
reservedActionIds: string[] = [],
): string {
const used = new Set([
...actions.map((action) => action.id),
...reservedActionIds,
]);
let suffix = 1;
while (used.has(`${ACTION_ID_PREFIX}${suffix}`)) suffix += 1;
return `${ACTION_ID_PREFIX}${suffix}`;
}
export function createRestAction(
actions: RestAction[],
reservedActionIds: string[] = [],
): RestAction {
return {
id: nextRestActionId(actions, reservedActionIds),
name: 'New REST Action',
description: '',
method: 'GET',
url: 'https://api.example.com',
headers: { Accept: 'application/json' },
queryParameters: {},
pathParameters: {},
bodyTemplate: '',
authenticationType: 'anonymous',
};
}
export function duplicateRestAction(
action: RestAction,
actions: RestAction[],
reservedActionIds: string[] = [],
): RestAction {
const duplicate: RestAction = {
...action,
id: nextRestActionId(actions, reservedActionIds),
name: `${action.name} Copy`,
authenticationType: 'anonymous',
headers: Object.fromEntries(
Object.entries(action.headers ?? {}).filter(
([key]) => !isCredentialHeaderName(key),
),
),
queryParameters: { ...(action.queryParameters ?? {}) },
pathParameters: { ...(action.pathParameters ?? {}) },
};
// New definitions must use top-level project.bindings, never the deprecated
// action-local responseMapping field.
delete duplicate.responseMapping;
return duplicate;
}
export function appendRestAction(
doc: ProjectDocument,
action: RestAction,
): ProjectDocument {
return {
...doc,
project: {
...doc.project,
actions: [...doc.project.actions, action],
},
};
}
export function replaceRestActionAt(
doc: ProjectDocument,
actionIndex: number,
action: RestAction,
): ProjectDocument {
if (actionIndex < 0 || actionIndex >= doc.project.actions.length) {
return doc;
}
return {
...doc,
project: {
...doc.project,
actions: doc.project.actions.map((candidate, index) =>
index === actionIndex ? action : candidate,
),
},
};
}
export function removeRestActionAt(
doc: ProjectDocument,
actionIndex: number,
): ProjectDocument {
if (actionIndex < 0 || actionIndex >= doc.project.actions.length) {
return doc;
}
return {
...doc,
project: {
...doc.project,
actions: doc.project.actions.filter((_, index) => index !== actionIndex),
},
};
}
function bindingReferencesAction(source: string, actionId: string): boolean {
const responseRoot = `actions.${actionId}.response`;
return source === responseRoot || source.startsWith(`${responseRoot}.`);
}
export function collectReferencedActionIds(doc: ProjectDocument): string[] {
const referencedIds = new Set<string>();
for (const page of doc.project.pages) {
for (const event of page.events ?? []) {
referencedIds.add(event.actionId);
}
for (const component of page.components) {
for (const event of component.events ?? []) {
referencedIds.add(event.actionId);
}
}
}
for (const binding of doc.project.bindings) {
const match = /^actions\.([^.]+)\.response(?:\.|$)/.exec(binding.source);
if (match) referencedIds.add(match[1]);
}
return [...referencedIds];
}
export function findActionReferences(
doc: ProjectDocument,
actionId: string,
): ActionReference[] {
const references: ActionReference[] = [];
for (const page of doc.project.pages) {
for (const event of page.events ?? []) {
if (event.actionId === actionId) {
references.push({
kind: 'pageEvent',
label: `Page "${page.name}" ${event.event}`,
});
}
}
for (const component of page.components) {
for (const event of component.events ?? []) {
if (event.actionId === actionId) {
references.push({
kind: 'componentEvent',
label: `Component "${component.name}" ${event.event}`,
});
}
}
}
}
for (const binding of doc.project.bindings) {
if (bindingReferencesAction(binding.source, actionId)) {
references.push({
kind: 'binding',
label: `Binding "${binding.id}"`,
});
}
}
return references;
}
export function buildDeleteConfirmation(
action: RestAction,
references: ActionReference[],
): string {
if (references.length === 0) {
return `Delete REST action "${action.name}"? This cannot be undone.`;
}
const referenceList = references.map((reference) => `- ${reference.label}`).join('\n');
return (
`REST action "${action.name}" is referenced by ${references.length} ` +
`configuration item${references.length === 1 ? '' : 's'}:\n\n` +
`${referenceList}\n\nDeleting it may leave dangling or ambiguous references. ` +
`Review the affected events and bindings after deletion. Continue?`
);
}
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const PATH_PARAMETER_NAME_PATTERN = /^\w+$/;
const CREDENTIAL_HEADER_NAMES = new Set([
'authorization',
'proxy-authorization',
'x-api-key',
'api-key',
'x-auth-token',
]);
function isCredentialHeaderName(key: string): boolean {
return CREDENTIAL_HEADER_NAMES.has(key.toLowerCase());
}
export type RequestMapKeyKind = 'generic' | 'header' | 'path';
function requestMapKeyError(
key: string,
keyKind: RequestMapKeyKind,
): string | null {
if (key !== key.trim()) {
return 'Keys cannot begin or end with whitespace.';
}
if (keyKind === 'header' && isCredentialHeaderName(key)) {
return 'Credential headers are configured through secure authentication in Slice 3.';
}
if (keyKind === 'header' && !HTTP_HEADER_NAME_PATTERN.test(key)) {
return 'Header names may contain only HTTP token characters.';
}
if (keyKind === 'path' && !PATH_PARAMETER_NAME_PATTERN.test(key)) {
return 'Path parameter names may contain only letters, numbers, and underscores.';
}
return null;
}
function validateStringRecord(
field: string,
value: Record<string, string> | undefined,
): ActionEditorIssue[] {
if (!value) return [];
const issues: ActionEditorIssue[] = [];
const keyKind: RequestMapKeyKind =
field === 'Headers'
? 'header'
: field === 'Path parameters'
? 'path'
: 'generic';
for (const [key, entryValue] of Object.entries(value)) {
if (!key.trim()) {
issues.push({
field,
severity: 'warn',
message: `${field} contains an empty key.`,
});
} else {
const keyError = requestMapKeyError(key, keyKind);
if (keyError) {
issues.push({
field,
severity: 'warn',
message: `${field} key "${key}" is invalid. ${keyError}`,
});
}
}
if (typeof entryValue !== 'string') {
issues.push({
field,
severity: 'warn',
message: `${field} value for "${key}" must be a string.`,
});
}
}
return issues;
}
export function isAbsoluteHttpUrl(value: string): boolean {
if (/\s/.test(value)) return false;
try {
const url = new URL(value);
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
url.hostname.length > 0 &&
url.username.length === 0 &&
url.password.length === 0
);
} catch {
return false;
}
}
export function validateRestAction(
action: RestAction,
actions: RestAction[],
): ActionEditorIssue[] {
const issues: ActionEditorIssue[] = [];
if (!action.id.trim()) {
issues.push({ field: 'id', severity: 'warn', message: 'Action ID is required.' });
} else if (!/^[A-Za-z0-9_-]+$/.test(action.id)) {
issues.push({
field: 'id',
severity: 'warn',
message: 'Action ID may contain only letters, numbers, underscores, and hyphens.',
});
}
if (actions.filter((candidate) => candidate.id === action.id).length > 1) {
issues.push({
field: 'id',
severity: 'warn',
message: `Action ID "${action.id}" is duplicated.`,
});
}
if (!action.name.trim()) {
issues.push({ field: 'name', severity: 'warn', message: 'Action name is required.' });
}
if (!action.url.trim()) {
issues.push({ field: 'url', severity: 'warn', message: 'Endpoint URL is required.' });
} else if (!isAbsoluteHttpUrl(action.url)) {
issues.push({
field: 'url',
severity: 'warn',
message: 'Endpoint URL must be absolute HTTP or HTTPS without spaces or embedded credentials.',
});
}
if (!HTTP_METHODS.includes(action.method)) {
issues.push({
field: 'method',
severity: 'warn',
message: `HTTP method "${action.method}" is not supported.`,
});
}
issues.push(...validateStringRecord('Headers', action.headers));
issues.push(...validateStringRecord('Query parameters', action.queryParameters));
issues.push(...validateStringRecord('Path parameters', action.pathParameters));
for (const [key, value] of Object.entries(action.pathParameters ?? {})) {
if (value.includes('{{')) {
issues.push({
field: 'Path parameters',
severity: 'warn',
message: `Path parameter "${key}" must use a static replacement value; templates are not supported yet.`,
});
}
}
if (
(action.method === 'GET' || action.method === 'DELETE') &&
action.bodyTemplate?.trim()
) {
issues.push({
field: 'bodyTemplate',
severity: 'info',
message: `${action.method} requests do not send the configured request body.`,
});
}
if (action.authenticationType !== 'anonymous') {
issues.push({
field: 'authenticationType',
severity: 'info',
message:
'Credential-backed authentication is configured but executes anonymously until Slice 3.',
});
}
return issues;
}
export function validateKeyValueRows(
rows: KeyValueRow[],
keyKind: RequestMapKeyKind = 'generic',
): string | null {
if (rows.some((row) => row.key.trim().length === 0)) {
return 'Every row needs a key before it can be added to the project document.';
}
for (const row of rows) {
const keyError = requestMapKeyError(row.key, keyKind);
if (keyError) return keyError;
}
const comparableKeys = rows.map((row) =>
keyKind === 'header' ? row.key.toLowerCase() : row.key,
);
const duplicateIndex = comparableKeys.findIndex(
(key, index) => comparableKeys.indexOf(key) !== index,
);
if (duplicateIndex >= 0) {
return `Duplicate key "${rows[duplicateIndex].key}".`;
}
return null;
}
export function keyValueRowsToRecord(
rows: KeyValueRow[],
): Record<string, string> {
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}

View File

@ -0,0 +1,57 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';
import type { ProjectDocument } from '../../types/project';
import { executeAction } from '../../api/proxyApi';
import { usePreviewRuntime } from './usePreviewRuntime';
jest.mock('../../api/proxyApi', () => ({ executeAction: jest.fn() }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const doc: ProjectDocument = {
schemaVersion: '0.1.0',
project: {
id: 'project', name: 'Project', variables: {}, bindings: [],
settings: {},
actions: [{ id: 'action_a', name: 'Action A', method: 'GET', url: 'https://example.com', authenticationType: 'anonymous' }],
pages: [{
id: 'page', name: 'Page', order: 0,
components: [{
id: 'button', name: 'runButton', type: 'Button',
position: { x: 0, y: 0 }, size: { width: 100, height: 40 },
properties: { label: 'Run' },
events: [{ event: 'onClick', actionId: 'action_a' }],
}],
}],
},
};
describe('Preview Button event dispatch', () => {
let container: HTMLDivElement;
let root: Root;
let runtime: ReturnType<typeof usePreviewRuntime>;
function Harness(): React.ReactElement {
runtime = usePreviewRuntime(doc);
return <div>{runtime.componentState.button?.error ?? ''}</div>;
}
beforeEach(() => {
container = document.createElement('div');
root = createRoot(container);
(executeAction as jest.Mock).mockResolvedValue({ ok: true, status: 200, headers: {}, body: { ran: true } });
act(() => root.render(<Harness />));
});
afterEach(() => {
act(() => root.unmount());
jest.clearAllMocks();
});
test('executes the REST action selected by canonical onClick configuration', async () => {
await act(async () => {
runtime.handleButtonClick('button');
await Promise.resolve();
await Promise.resolve();
});
expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ id: 'action_a' }));
});
});

View File

@ -0,0 +1,55 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';
import ButtonEventEditor from './ButtonEventEditor';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const actions = [
{ id: 'action_a', name: 'Action A', method: 'GET' as const, url: 'https://example.com', authenticationType: 'anonymous' as const },
{ id: 'action_b', name: 'Action B', method: 'POST' as const, url: 'https://example.com', authenticationType: 'anonymous' as const },
];
describe('ButtonEventEditor', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test('shows a clear no-action state and emits canonical onClick changes', () => {
const onChange = jest.fn();
act(() => root.render(<ButtonEventEditor actions={actions} events={[]} onChange={onChange} />));
const select = container.querySelector<HTMLSelectElement>('[data-testid="button-onclick-action"]')!;
expect(select.value).toBe('');
expect(select.options[0].text).toBe('No action');
act(() => {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!;
setter.call(select, 'action_b');
select.dispatchEvent(new Event('change', { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith([{ event: 'onClick', actionId: 'action_b' }]);
});
test('surfaces a missing action reference and allows clearing it', () => {
const onChange = jest.fn();
act(() => root.render(
<ButtonEventEditor actions={actions} events={[{ event: 'onClick', actionId: 'missing' }]} onChange={onChange} />,
));
expect(container.querySelector('[role="alert"]')?.textContent).toContain('missing action "missing"');
const select = container.querySelector<HTMLSelectElement>('[data-testid="button-onclick-action"]')!;
act(() => {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!;
setter.call(select, '');
select.dispatchEvent(new Event('change', { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith([]);
});
});

View File

@ -0,0 +1,30 @@
import React from 'react';
import type { ComponentEvent, RestAction } from '../../types/project';
import { assignButtonOnClick, getButtonOnClickActionId } from './componentEventUtils';
import styles from './VisualEditor.module.css';
type Props = { events: ComponentEvent[] | undefined; actions: RestAction[]; onChange: (events: ComponentEvent[]) => void };
export default function ButtonEventEditor({ events, actions, onChange }: Props): React.ReactElement {
const actionId = getButtonOnClickActionId(events);
const missingReference = actionId !== '' && !actions.some((action) => action.id === actionId);
return (
<div data-testid="button-onclick-editor" style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #d8dee4' }}>
<div className={styles.infoKey} style={{ display: 'block', marginBottom: 6 }}>Events</div>
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-onclick-action">On click</label>
<select id="prop-onclick-action" data-testid="button-onclick-action" className={styles.infoInput}
value={actionId} onChange={(event) => onChange(assignButtonOnClick(events, event.target.value))}
style={missingReference ? { borderColor: '#b91c1c' } : undefined}>
<option value="">No action</option>
{missingReference && <option value={actionId}>Missing action: {actionId}</option>}
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
</select>
</div>
{actions.length === 0 && !missingReference && <div style={{ fontSize: 10, color: '#57606a' }}>Create a REST action in Actions &amp; Bindings first.</div>}
{missingReference && <div role="alert" style={{ fontSize: 10, color: '#b91c1c' }}>
This onClick event references missing action &quot;{actionId}&quot;. Select a valid action or clear it.
</div>}
</div>
);
}

View File

@ -5,6 +5,7 @@ import ProjectToolbar from '../ProjectToolbar/ProjectToolbar';
import { useProject } from '../../context/ProjectContext';
import styles from './VisualEditor.module.css';
import type { ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
import ButtonEventEditor from './ButtonEventEditor';
// ── Dropdown options row editor ───────────────────────────────────────────────
@ -290,6 +291,7 @@ function VisualEditor(): React.ReactElement {
updateComponentProperty,
updateComponentName,
updateComponentSize,
updateComponentEvents,
} = canvas;
const selectedComponent = activePage.components.find((x) => x.id === selectedId) ?? null;
@ -648,6 +650,13 @@ function VisualEditor(): React.ReactElement {
/>
</>
)}
{selectedComponent.type === 'Button' && (
<ButtonEventEditor
events={selectedComponent.events}
actions={doc.project.actions}
onChange={(events) => updateComponentEvents(selectedComponent.id, events)}
/>
)}
</div>
) : (
<p className={styles.infoEmpty}>No component selected</p>

View File

@ -0,0 +1,24 @@
import { assignButtonOnClick, getButtonOnClickActionId } from './componentEventUtils';
describe('Button onClick canonical synchronization', () => {
test('adds, changes, and clears an onClick assignment', () => {
expect(assignButtonOnClick(undefined, 'action_a')).toEqual([{ event: 'onClick', actionId: 'action_a' }]);
expect(assignButtonOnClick([{ event: 'onClick', actionId: 'action_a' }], 'action_b')).toEqual([{ event: 'onClick', actionId: 'action_b' }]);
expect(assignButtonOnClick([{ event: 'onClick', actionId: 'action_a' }], '')).toEqual([]);
});
test('preserves unrelated events while replacing duplicate onClick entries', () => {
expect(assignButtonOnClick([
{ event: 'onChange', actionId: 'action_change', inputMap: { value: 'components.input.value' } },
{ event: 'onClick', actionId: 'old_a', inputMap: { inert: 'legacy' } },
{ event: 'onClick', actionId: 'old_b' },
{ event: 'onBlur', actionId: 'action_blur' },
], 'new_action')).toEqual([
{ event: 'onChange', actionId: 'action_change', inputMap: { value: 'components.input.value' } },
{ event: 'onClick', actionId: 'new_action' },
{ event: 'onBlur', actionId: 'action_blur' },
]);
});
test('reports no action when onClick is absent', () => {
expect(getButtonOnClickActionId([{ event: 'onChange', actionId: 'action_a' }])).toBe('');
});
});

View File

@ -0,0 +1,17 @@
import type { ComponentEvent } from '../../types/project';
export function getButtonOnClickActionId(events: ComponentEvent[] | undefined): string {
return events?.find((event) => event.event === 'onClick')?.actionId ?? '';
}
export function assignButtonOnClick(events: ComponentEvent[] | undefined, actionId: string): ComponentEvent[] {
const current = events ?? [];
const firstOnClickIndex = current.findIndex((event) => event.event === 'onClick');
if (!actionId) return current.filter((event) => event.event !== 'onClick');
const nextEvent: ComponentEvent = { event: 'onClick', actionId };
if (firstOnClickIndex < 0) return [...current, nextEvent];
return current.flatMap((event, index) => {
if (event.event !== 'onClick') return [event];
return index === firstOnClickIndex ? [nextEvent] : [];
});
}

View File

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import type {
ProjectDocument,
CanvasComponent,
ComponentEvent,
ComponentType,
Page,
} from '../types/project';
@ -64,6 +65,7 @@ export type CanvasActions = {
updateComponentProperty: (id: string, key: string, value: unknown) => void;
updateComponentName: (id: string, name: string) => void;
updateComponentSize: (id: string, width: number, height: number) => void;
updateComponentEvents: (id: string, events: ComponentEvent[]) => void;
};
export function useCanvasActions(
@ -199,6 +201,20 @@ export function useCanvasActions(
[updatePage],
);
const updateComponentEvents = useCallback(
(id: string, events: ComponentEvent[]) => {
updatePage((p) => ({
...p,
components: p.components.map((c) =>
c.id === id
? { ...c, ...(events.length > 0 ? { events } : { events: undefined }) }
: c,
),
}));
},
[updatePage],
);
return {
activePage,
addComponent,
@ -207,5 +223,6 @@ export function useCanvasActions(
updateComponentProperty,
updateComponentName,
updateComponentSize,
updateComponentEvents,
};
}

View File

@ -100,8 +100,9 @@ export type ComponentEvent = {
/** ID of the project-level action to execute when this event fires. */
actionId: string;
/**
* Maps action input parameter names to runtime value expressions.
* Not used in Step 15 (anonymous execution only).
* Reserved legacy field retained for schema compatibility.
* Preview does not consume inputMap. Request inputs use templates directly
* in RestAction url, headers, queryParameters, and bodyTemplate.
*/
inputMap?: Record<string, string>;
};
@ -270,6 +271,8 @@ export type Page = {
description?: string;
order?: number;
components: CanvasComponent[];
/** Page-level lifecycle event handlers (for example, onLoad → actionId). */
events?: ComponentEvent[];
};
export type Project = {

View File

@ -476,7 +476,7 @@
},
"inputMap": {
"type": "object",
"description": "Maps action input parameter names to runtime value expressions (component refs or variable refs).",
"description": "Reserved legacy field retained for compatibility. Preview request inputs use templates in REST action request fields; inputMap is not executed.",
"additionalProperties": { "type": "string" },
"default": {}
}