diff --git a/BASELINE.md b/BASELINE.md index c884bb9..17dcee0 100644 --- a/BASELINE.md +++ b/BASELINE.md @@ -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 \ + --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. diff --git a/CODEX.md b/CODEX.md index 14830cc..4a162ca 100644 --- a/CODEX.md +++ b/CODEX.md @@ -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. diff --git a/SLICE2.md b/SLICE2.md index 576bc8b..18a6ff5 100644 --- a/SLICE2.md +++ b/SLICE2.md @@ -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. diff --git a/TASKS.md b/TASKS.md index 3970786..df39e0e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 61f6f08..0c7cb67 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -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..value}}` or `{{variables.}}`. +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. | --- diff --git a/examples/project-definitions/valid-visual-rest-actions.json b/examples/project-definitions/valid-visual-rest-actions.json new file mode 100644 index 0000000..79ed23a --- /dev/null +++ b/examples/project-definitions/valid-visual-rest-actions.json @@ -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": {} + } +} diff --git a/frontend/src/components/ActionInspector/ActionInspector.module.css b/frontend/src/components/ActionInspector/ActionInspector.module.css index 5a97bc5..aa4e7c1 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.module.css +++ b/frontend/src/components/ActionInspector/ActionInspector.module.css @@ -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; + } +} diff --git a/frontend/src/components/ActionInspector/ActionInspector.test.tsx b/frontend/src/components/ActionInspector/ActionInspector.test.tsx new file mode 100644 index 0000000..9db3abe --- /dev/null +++ b/frontend/src/components/ActionInspector/ActionInspector.test.tsx @@ -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 ( + <> +
{JSON.stringify(doc)}
+ {String(isDirty)} + + ); +} + +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('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( + + + + , + ); + }); + }); + + 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('[data-testid="action-authentication"]'), + 'bearerToken', + ); + expect(currentDocument(container).project.actions[0].authenticationType) + .toBe('anonymous'); + + setControlValue( + container.querySelector('[data-testid="action-name"]'), + 'Create inventory item', + ); + setControlValue( + container.querySelector('[data-testid="action-method"]'), + 'POST', + ); + setControlValue( + container.querySelector('[data-testid="action-url"]'), + 'https://api.example.com/items', + ); + setControlValue( + container.querySelector('[data-testid="action-body"]'), + '{"name":"{{components.nameInput.value}}"}', + ); + + click(container.querySelector('button[aria-label="Add query parameters row"]')); + setControlValue( + container.querySelector('input[aria-label="Query parameters key"]'), + 'limit', + ); + setControlValue( + container.querySelector('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( + '[data-testid="add-rest-action"]', + ); + expect(addButton?.disabled).toBe(true); + + const nameInput = container.querySelector( + '[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( + '[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( + '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('[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.', + ); + }); +}); diff --git a/frontend/src/components/ActionInspector/ActionInspector.tsx b/frontend/src/components/ActionInspector/ActionInspector.tsx index ff94fba..71885eb 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.tsx +++ b/frontend/src/components/ActionInspector/ActionInspector.tsx @@ -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; +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(); @@ -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({ 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 ── */} +
+ + + +
+ {/* ── Test Action ── */}
+
{/* Summary banner */} @@ -856,17 +1010,34 @@ function ActionInspector(): React.ReactElement { {actions.length === 0 ? (
- No REST actions defined. Add actions to{' '} - project.actions in the JSON Editor. + No REST actions defined. Add one to begin configuring the canonical + project document visually.
) : ( - actions.map((action) => ( - + actions.map((action, actionIndex) => ( + + setEditingActionIndex(actionIndex)} + onDuplicate={() => handleDuplicateAction(action)} + onDelete={() => handleDeleteAction(actionIndex, action)} + /> + {editingActionIndex === actionIndex && ( + + handleUpdateAction(actionIndex, updatedAction) + } + onDone={() => setEditingActionIndex(null)} + /> + )} + )) )} diff --git a/frontend/src/components/ActionInspector/RestActionEditor.tsx b/frontend/src/components/ActionInspector/RestActionEditor.tsx new file mode 100644 index 0000000..cc07a66 --- /dev/null +++ b/frontend/src/components/ActionInspector/RestActionEditor.tsx @@ -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; + onChange: (value: Record) => void; + onValidityChange: (valid: boolean) => void; +}; + +function makeRows(value: Record): 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(() => makeRows(value)); + const [error, setError] = useState(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 ( +
+
+ {title} + +
+ + {rows.length === 0 && ( +
No entries configured.
+ )} + + {rows.map((row) => ( +
+ handleChange(row.id, 'key', event.target.value)} + /> + handleChange(row.id, 'value', event.target.value)} + /> + +
+ ))} + + {error && ( +
+ {error} Invalid rows remain local until corrected. +
+ )} +
+ ); +} + +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>(() => new Set()); + const lastEmittedAction = useRef(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( + (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 ( +
{ + event.preventDefault(); + if (hasInvalidDrafts) return; + onDone(); + }} + > +
+
+
Edit REST action
+
+ Valid edits update the canonical project document immediately. +
+
+ +
+ + {issues.length > 0 && ( +
+ {issues.map((issue, index) => ( +
+ {issue.message} +
+ ))} +
+ )} + +
+ + + +
+ +