Add Slice 2 visual REST configuration
This commit is contained in:
parent
b9697cc8b1
commit
3994eaa397
75
BASELINE.md
75
BASELINE.md
@ -79,6 +79,77 @@ User validation completed successfully on 2026-07-18:
|
|||||||
- Save/load persistence and existing-component regression checks pass.
|
- 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
|
## Runtime Startup
|
||||||
|
|
||||||
Initial backend startup against a clean SQLite database failed with:
|
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
|
- [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.
|
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.
|
||||||
|
|||||||
54
CODEX.md
54
CODEX.md
@ -82,6 +82,9 @@ All eleven schema-supported MVP components are available in the palette and have
|
|||||||
### REST and Preview runtime
|
### REST and Preview runtime
|
||||||
|
|
||||||
- REST action model supporting GET, POST, PUT, PATCH, and DELETE
|
- 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
|
- Anonymous server-side REST execution
|
||||||
- Request URL, header, query, path, and body templates
|
- Request URL, header, query, path, and body templates
|
||||||
- Normalized proxy response envelope
|
- 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.
|
- Secure credential storage and secret resolution are not implemented.
|
||||||
- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation.
|
- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation.
|
||||||
- Sanitized execution history and troubleshooting views are not implemented.
|
- 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.
|
- 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.
|
- Backend and end-to-end automated test coverage remains incomplete.
|
||||||
- IBM Bob/watsonx will not be used; AI is provider-neutral and post-MVP.
|
- 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.
|
- The working tree was clean immediately after the push.
|
||||||
- All `:Zone.Identifier` sidecar files were removed before the initial commit.
|
- All `:Zone.Identifier` sidecar files were removed before the initial commit.
|
||||||
- Node 20/npm 10 clean installs pass after repairing the frontend lockfile.
|
- Node 20/npm 10 clean installs pass after repairing the frontend lockfile.
|
||||||
- Frontend validation passes: 7 suites and 433 tests, plus the production build.
|
- 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.
|
- 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.
|
- Docker Compose builds and starts both services.
|
||||||
- Health, CRUD, cleanup, and restart persistence pass.
|
- Health, CRUD, cleanup, and restart persistence pass.
|
||||||
- See `BASELINE.md` for exact evidence and remaining release work.
|
- 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:
|
Unless the user chooses a different priority, proceed in this order:
|
||||||
|
|
||||||
1. Add visual REST action, event, and binding configuration.
|
1. Add visual component-event configuration, beginning with Button `onClick`, then request input mapping.
|
||||||
2. Add validation to every project save path.
|
2. Add visual response bindings, variables, and page-load action configuration.
|
||||||
3. Implement authentication and secure secret handling.
|
3. Add validation to every project save path.
|
||||||
4. Harden the REST proxy and add sanitized execution logging.
|
4. Implement authentication and secure secret handling.
|
||||||
5. Add backend, end-to-end, and security regression tests.
|
5. Harden the REST proxy and add sanitized execution logging.
|
||||||
6. Reconcile and consolidate project documentation.
|
6. Add backend, end-to-end, and security regression tests.
|
||||||
|
7. Reconcile and consolidate project documentation.
|
||||||
|
|
||||||
See `TASKS.md` for the complete actionable checklist.
|
See `TASKS.md` for the complete actionable checklist.
|
||||||
|
|
||||||
@ -172,12 +178,26 @@ After material work:
|
|||||||
|
|
||||||
## Latest Handoff
|
## 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.
|
- Completed Slice 2 Increment 1: visual anonymous REST action creation, editing, duplication, and reference-aware deletion.
|
||||||
- Added runtime value and response-binding support while keeping ephemeral values out of canonical JSON.
|
- Added method, URL, header, query, static path-parameter, and body controls with immediate canonical-document synchronization.
|
||||||
- Scoped Container/Card to a flat presentational title/body contract for MVP; nesting remains post-MVP.
|
- Kept invalid drafts and all execution state outside canonical JSON; new and duplicated actions are anonymous and omit deprecated `action.responseMapping`.
|
||||||
- Added `valid-mvp-components.json` and focused renderer/binding tests.
|
- Added focused validation for action IDs, URLs, duplicate IDs, header names, unsupported path-parameter templates, and referenced deletion.
|
||||||
- Validation passes: 7 frontend suites / 433 tests, frontend production build, backend TypeScript build, and the new schema fixture.
|
- Added `valid-visual-rest-actions.json` and 23 focused tests.
|
||||||
- User completed the full manual Slice 1 checklist successfully.
|
- 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.
|
||||||
- Next recommended action: begin `SLICE2.md`.
|
- 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.
|
||||||
|
|||||||
57
SLICE2.md
57
SLICE2.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Not started
|
In progress — Increment 2 Button `onClick` vertical increment complete; request-input UI next
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
@ -27,18 +27,30 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Design a consistent workflow for editing actions, events, bindings, and variables.
|
- [x] Design a consistent, progressively disclosed workflow for editing actions, events, bindings, variables, and page-load behavior.
|
||||||
- [ ] Implement REST Action list, create, edit, duplicate, and delete operations.
|
- [x] Implement REST Action list, create, edit, duplicate, and reference-aware delete operations.
|
||||||
- [ ] Add method, URL, headers, query, path, body, timeout, and authentication-type controls.
|
- [x] Add method, URL, headers, query, path, and body controls for anonymous REST actions.
|
||||||
- [ ] Implement component event configuration, beginning with Button `onClick`.
|
- [ ] Decide and implement the canonical timeout field before exposing a timeout control; the current schema and runtime use a fixed backend timeout.
|
||||||
- [ ] Implement request input mapping from components and variables.
|
- [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.
|
- [ ] Implement response source and component/variable target selection.
|
||||||
- [ ] Default new action-response bindings to `onSuccess`.
|
- [ ] Default new action-response bindings to `onSuccess`.
|
||||||
- [ ] Implement page-load action configuration for initial data population.
|
- [ ] Implement page-load action configuration for initial data population.
|
||||||
- [ ] Add variable declaration and default-value editing.
|
- [ ] Add variable declaration and default-value editing.
|
||||||
- [ ] Warn before deleting referenced actions, variables, or components.
|
- [ ] Warn before deleting referenced actions, variables, or components. Action deletion is covered; variable and component deletion remain.
|
||||||
- [ ] Ensure every visual edit immediately updates canonical JSON.
|
- [x] Ensure every REST action visual edit immediately updates canonical JSON while invalid drafts remain local.
|
||||||
- [ ] Add tests, examples, and documentation updates.
|
- [ ] 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
|
## Acceptance Criteria
|
||||||
|
|
||||||
@ -50,9 +62,11 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- [ ] Frontend TypeScript check and production build pass.
|
- [ ] Standalone frontend TypeScript check passes. TypeScript 4.9 currently cannot parse the resolved `@types/node@26.1.0`; the CRA production compile passes.
|
||||||
- [ ] Editor interaction tests pass.
|
- [x] Frontend production build passes through the Button `onClick` increment.
|
||||||
- [ ] Generated canonical JSON validates against the schema.
|
- [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.
|
- [ ] Manual workflow-launcher and dependent-data scenarios pass.
|
||||||
|
|
||||||
## Risks and Open Questions
|
## Risks and Open Questions
|
||||||
@ -63,10 +77,21 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
|
|||||||
|
|
||||||
## Progress Log
|
## 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
|
## Handoff
|
||||||
|
|
||||||
- Last completed: Slice plan created.
|
- Last completed: Increment 2 visual Button `onClick` configuration, automated validation, and user manual acceptance.
|
||||||
- Next action: Design the action editor data flow and acceptance-test project.
|
- Next action: Add request-input UI that writes executed REST templates for component and variable values.
|
||||||
- Known blockers: Coordinate secret-reference fields with Slice 3.
|
- Known blockers: None for request-input implementation. Standalone frontend `tsc` retains its recorded tooling limitation.
|
||||||
|
|||||||
4
TASKS.md
4
TASKS.md
@ -70,8 +70,8 @@ This document summarizes the current implementation and the remaining work requi
|
|||||||
|
|
||||||
### Visual configuration workflows
|
### Visual configuration workflows
|
||||||
|
|
||||||
- [ ] Implement a visual REST Action editor for creating, editing, and deleting actions without hand-editing JSON.
|
- [x] Implement a visual REST Action editor for creating, editing, duplicating, and deleting anonymous actions without hand-editing JSON.
|
||||||
- [ ] Implement visual component-event configuration, including binding a button to an action.
|
- [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 a visual project-binding editor for request inputs and response targets.
|
||||||
- [ ] Implement page-load action configuration for automatically populated components.
|
- [ ] Implement page-load action configuration for automatically populated components.
|
||||||
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
|
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
|
||||||
|
|||||||
@ -110,14 +110,18 @@ Allowed `type` values:
|
|||||||
"events": [
|
"events": [
|
||||||
{
|
{
|
||||||
"event": "onClick",
|
"event": "onClick",
|
||||||
"actionId": "action_trigger_workflow",
|
"actionId": "action_trigger_workflow"
|
||||||
"inputMap": { "workflowId": "components.workflowIdInput.value" }
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"bindings": []
|
"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)
|
### `Action` (REST Action)
|
||||||
|
|
||||||
A REST API call definition. Required fields: `id`, `name`, `method`, `url`, `authenticationType`.
|
A REST API call definition. Required fields: `id`, `name`, `method`, `url`, `authenticationType`.
|
||||||
@ -144,10 +148,18 @@ Allowed `authenticationType` values: `anonymous` · `bearerToken` · `basicAuth`
|
|||||||
"workflowId": "{{workflowId}}"
|
"workflowId": "{{workflowId}}"
|
||||||
},
|
},
|
||||||
"bodyTemplate": "{\"params\": {{params}}}",
|
"bodyTemplate": "{\"params\": {{params}}}",
|
||||||
"authenticationType": "bearerToken",
|
"authenticationType": "bearerToken"
|
||||||
"responseMapping": [
|
}
|
||||||
{ "source": "data.status", "target": "variables.lastStatus" }
|
```
|
||||||
]
|
|
||||||
|
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. |
|
| `pathParameters` | `object` | — | Path segment substitutions. Keys match `{{paramName}}` in the URL. |
|
||||||
| `bodyTemplate` | `string` | — | Request body template. Use `{{variableName}}` for runtime substitutions. |
|
| `bodyTemplate` | `string` | — | Request body template. Use `{{variableName}}` for runtime substitutions. |
|
||||||
| `authenticationType` | `string` | ✅ | Authentication strategy (see allowed values above). Credentials are never stored here. |
|
| `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`
|
### `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-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-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-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. |
|
| `examples/project-definitions/invalid-missing-required.json` | Intentionally invalid document showing schema errors. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
90
examples/project-definitions/valid-visual-rest-actions.json
Normal file
90
examples/project-definitions/valid-visual-rest-actions.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -447,3 +447,277 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 1px 6px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
237
frontend/src/components/ActionInspector/ActionInspector.test.tsx
Normal file
237
frontend/src/components/ActionInspector/ActionInspector.test.tsx
Normal 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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
|
* 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,
|
* Visual REST action authoring plus binding inspection for the current project,
|
||||||
* read directly from ProjectContext.
|
* synchronized through the canonical document in ProjectContext.
|
||||||
*
|
*
|
||||||
* ── Diagnostics computed ──────────────────────────────────────────────────────
|
* ── Diagnostics computed ──────────────────────────────────────────────────────
|
||||||
* Per action (Step 15.6):
|
* Per action (Step 15.6):
|
||||||
@ -35,7 +35,7 @@
|
|||||||
* • Binding targeting Table.rows / Table.value (warning)
|
* • 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 { useProject } from '../../context/ProjectContext';
|
||||||
import { executeAction } from '../../api/proxyApi';
|
import { executeAction } from '../../api/proxyApi';
|
||||||
import type { ProxyResponse } from '../../api/proxyApi';
|
import type { ProxyResponse } from '../../api/proxyApi';
|
||||||
@ -49,6 +49,18 @@ import {
|
|||||||
parseVariableTargetPath,
|
parseVariableTargetPath,
|
||||||
} from '../Preview/bindingUtils';
|
} from '../Preview/bindingUtils';
|
||||||
import styles from './ActionInspector.module.css';
|
import styles from './ActionInspector.module.css';
|
||||||
|
import RestActionEditor from './RestActionEditor';
|
||||||
|
import {
|
||||||
|
appendRestAction,
|
||||||
|
buildDeleteConfirmation,
|
||||||
|
collectReferencedActionIds,
|
||||||
|
createRestAction,
|
||||||
|
duplicateRestAction,
|
||||||
|
findActionReferences,
|
||||||
|
removeRestActionAt,
|
||||||
|
replaceRestActionAt,
|
||||||
|
validateRestAction,
|
||||||
|
} from './actionEditorUtils';
|
||||||
|
|
||||||
// ── Diagnostic types ──────────────────────────────────────────────────────────
|
// ── Diagnostic types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -60,20 +72,24 @@ type Diagnostic = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All diagnostics indexed by their subject ID so cards can look up their own.
|
* All diagnostics indexed by a stable render subject so cards can look up their own.
|
||||||
* Keys: action IDs, binding IDs.
|
* Keys: action indices (isolating duplicate IDs), binding IDs, and synthetic subjects.
|
||||||
* Also a special key "__componentEvents" for component-event issues not tied to
|
* Also a special key "__componentEvents" for component-event issues not tied to
|
||||||
* a specific binding (currently unused — shown on binding cards that reference
|
* a specific binding (currently unused — shown on binding cards that reference
|
||||||
* the missing action).
|
* the missing action).
|
||||||
*/
|
*/
|
||||||
type DiagMap = Record<string, Diagnostic[]>;
|
type DiagMap = Record<string, Diagnostic[]>;
|
||||||
|
|
||||||
|
function actionDiagnosticKey(actionIndex: number): string {
|
||||||
|
return `__action_${actionIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pure diagnostic computation ───────────────────────────────────────────────
|
// ── Pure diagnostic computation ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes all diagnostics from the project in a single pass.
|
* Computes all diagnostics from the project in a single pass.
|
||||||
* Never throws — any unexpected input is handled gracefully.
|
* 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(
|
function computeDiagnostics(
|
||||||
actions: RestAction[],
|
actions: RestAction[],
|
||||||
@ -115,11 +131,16 @@ function computeDiagnostics(
|
|||||||
|
|
||||||
// ── 1. Diagnostics per action ─────────────────────────────────────────────
|
// ── 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
|
// 1a. Untriggered action
|
||||||
if (!triggeredActionIds.has(action.id)) {
|
if (!triggeredActionIds.has(action.id)) {
|
||||||
add(
|
add(
|
||||||
action.id,
|
diagnosticKey,
|
||||||
'info',
|
'info',
|
||||||
`Action "${action.name}" is not triggered by any component event. ` +
|
`Action "${action.name}" is not triggered by any component event. ` +
|
||||||
`Add an events entry on a component: { "event": "onClick", "actionId": "${action.id}" }`,
|
`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.
|
// runtime does not interpolate pathParameters values at all.
|
||||||
if (token.location.startsWith('pathParameters.')) {
|
if (token.location.startsWith('pathParameters.')) {
|
||||||
add(
|
add(
|
||||||
action.id,
|
diagnosticKey,
|
||||||
'warn',
|
'warn',
|
||||||
`Template "${token.raw}" at ${token.location} uses a variable placeholder ` +
|
`Template "${token.raw}" at ${token.location} uses a variable placeholder ` +
|
||||||
`in a path-parameter value. Variable interpolation is not supported in ` +
|
`in a path-parameter value. Variable interpolation is not supported in ` +
|
||||||
@ -147,7 +168,7 @@ function computeDiagnostics(
|
|||||||
// but use a separate, clearly scoped message.
|
// but use a separate, clearly scoped message.
|
||||||
if (!declaredVariableNames.has(token.name)) {
|
if (!declaredVariableNames.has(token.name)) {
|
||||||
add(
|
add(
|
||||||
action.id,
|
diagnosticKey,
|
||||||
'warn',
|
'warn',
|
||||||
`Template "${token.raw}" at ${token.location} also references variable ` +
|
`Template "${token.raw}" at ${token.location} also references variable ` +
|
||||||
`"${token.name}" which is not declared in project.variables.`,
|
`"${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)
|
// Variable token in a supported location — check declaration (Step 18.1)
|
||||||
if (!declaredVariableNames.has(token.name)) {
|
if (!declaredVariableNames.has(token.name)) {
|
||||||
add(
|
add(
|
||||||
action.id,
|
diagnosticKey,
|
||||||
'warn',
|
'warn',
|
||||||
`Template "${token.raw}" at ${token.location} references variable ` +
|
`Template "${token.raw}" at ${token.location} references variable ` +
|
||||||
`"${token.name}" which is not declared in project.variables.`,
|
`"${token.name}" which is not declared in project.variables.`,
|
||||||
@ -171,7 +192,7 @@ function computeDiagnostics(
|
|||||||
// Component token — check property and existence
|
// Component token — check property and existence
|
||||||
if (token.propertyName !== 'value') {
|
if (token.propertyName !== 'value') {
|
||||||
add(
|
add(
|
||||||
action.id,
|
diagnosticKey,
|
||||||
'warn',
|
'warn',
|
||||||
`Template "${token.raw}" at ${token.location} references property ` +
|
`Template "${token.raw}" at ${token.location} references property ` +
|
||||||
`"${token.propertyName ?? '?'}". Only "value" is supported. ` +
|
`"${token.propertyName ?? '?'}". Only "value" is supported. ` +
|
||||||
@ -182,7 +203,7 @@ function computeDiagnostics(
|
|||||||
// Missing component
|
// Missing component
|
||||||
if (token.componentName && !componentsByName.has(token.componentName)) {
|
if (token.componentName && !componentsByName.has(token.componentName)) {
|
||||||
add(
|
add(
|
||||||
action.id,
|
diagnosticKey,
|
||||||
'warn',
|
'warn',
|
||||||
`Template "${token.raw}" at ${token.location} references component ` +
|
`Template "${token.raw}" at ${token.location} references component ` +
|
||||||
`"${token.componentName}" which does not exist on any page.`,
|
`"${token.componentName}" which does not exist on any page.`,
|
||||||
@ -193,7 +214,7 @@ function computeDiagnostics(
|
|||||||
for (const bad of malformed) {
|
for (const bad of malformed) {
|
||||||
const isMalformedVar = classifyVariableExpression(bad.raw) === 'malformed';
|
const isMalformedVar = classifyVariableExpression(bad.raw) === 'malformed';
|
||||||
add(
|
add(
|
||||||
action.id,
|
diagnosticKey,
|
||||||
'warn',
|
'warn',
|
||||||
isMalformedVar
|
isMalformedVar
|
||||||
? `Malformed variable template "${bad.raw}" at ${bad.location}: ` +
|
? `Malformed variable template "${bad.raw}" at ${bad.location}: ` +
|
||||||
@ -203,7 +224,7 @@ function computeDiagnostics(
|
|||||||
`missing closing "}}". Check your template syntax.`,
|
`missing closing "}}". Check your template syntax.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// ── Index: component name → all components with that name (duplicate check) ─
|
// ── Index: component name → all components with that name (duplicate check) ─
|
||||||
const componentsByNameAll = new Map<string, CanvasComponent[]>();
|
const componentsByNameAll = new Map<string, CanvasComponent[]>();
|
||||||
@ -568,17 +589,43 @@ type ActionCardProps = {
|
|||||||
action: RestAction;
|
action: RestAction;
|
||||||
diags: Diagnostic[];
|
diags: Diagnostic[];
|
||||||
allComponents: CanvasComponent[];
|
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 [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 handleTest = useCallback(async () => {
|
||||||
|
const generation = ++requestGeneration.current;
|
||||||
setTest({ status: 'running' });
|
setTest({ status: 'running' });
|
||||||
try {
|
try {
|
||||||
const response = await executeAction(action);
|
const response = await executeAction(action);
|
||||||
|
if (generation !== requestGeneration.current) return;
|
||||||
setTest({ status: 'ok', response });
|
setTest({ status: 'ok', response });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (generation !== requestGeneration.current) return;
|
||||||
setTest({
|
setTest({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
@ -586,6 +633,8 @@ function ActionCard({ action, diags, allComponents }: ActionCardProps): React.Re
|
|||||||
}
|
}
|
||||||
}, [action]);
|
}, [action]);
|
||||||
|
|
||||||
|
const controlsLocked = actionsLocked || test.status === 'running';
|
||||||
|
|
||||||
// Compute template info for display
|
// Compute template info for display
|
||||||
const { tokens, malformed } = useMemo(
|
const { tokens, malformed } = useMemo(
|
||||||
() => extractTemplates(action),
|
() => extractTemplates(action),
|
||||||
@ -662,11 +711,41 @@ function ActionCard({ action, diags, allComponents }: ActionCardProps): React.Re
|
|||||||
{/* ── Diagnostics ── */}
|
{/* ── Diagnostics ── */}
|
||||||
<DiagList diags={diags} />
|
<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 ── */}
|
{/* ── Test Action ── */}
|
||||||
<div className={styles.testRow}>
|
<div className={styles.testRow}>
|
||||||
<button
|
<button
|
||||||
className={styles.testBtn}
|
className={styles.testBtn}
|
||||||
disabled={test.status === 'running'}
|
disabled={test.status === 'running' || actionsLocked}
|
||||||
onClick={handleTest}
|
onClick={handleTest}
|
||||||
>
|
>
|
||||||
{test.status === 'running' ? 'Running…' : 'Test Action'}
|
{test.status === 'running' ? 'Running…' : 'Test Action'}
|
||||||
@ -793,7 +872,7 @@ function BindingCard({
|
|||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActionInspector(): React.ReactElement {
|
function ActionInspector(): React.ReactElement {
|
||||||
const { doc } = useProject();
|
const { doc, setDoc } = useProject();
|
||||||
const { actions, bindings, pages, variables } = doc.project;
|
const { actions, bindings, pages, variables } = doc.project;
|
||||||
|
|
||||||
const allComponents = useMemo(
|
const allComponents = useMemo(
|
||||||
@ -801,6 +880,60 @@ function ActionInspector(): React.ReactElement {
|
|||||||
[pages],
|
[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
|
// Compute all diagnostics once per render cycle
|
||||||
const diagMap = useMemo(
|
const diagMap = useMemo(
|
||||||
() => computeDiagnostics(actions, bindings, allComponents, variables),
|
() => computeDiagnostics(actions, bindings, allComponents, variables),
|
||||||
@ -809,11 +942,17 @@ function ActionInspector(): React.ReactElement {
|
|||||||
|
|
||||||
// Count total warnings/infos for section summary banners
|
// Count total warnings/infos for section summary banners
|
||||||
const actionWarnings = actions.reduce(
|
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,
|
0,
|
||||||
);
|
);
|
||||||
const actionInfos = actions.reduce(
|
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,
|
0,
|
||||||
);
|
);
|
||||||
const bindingWarnings = bindings.reduce(
|
const bindingWarnings = bindings.reduce(
|
||||||
@ -828,10 +967,11 @@ function ActionInspector(): React.ReactElement {
|
|||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div className={styles.pageTitle}>Actions & Bindings</div>
|
<div className={styles.pageTitle}>Actions & Bindings</div>
|
||||||
<div className={styles.pageSubtitle}>
|
<div className={styles.pageSubtitle}>
|
||||||
Read-only inspector for the current project's REST actions and
|
Create and configure anonymous REST actions here without editing
|
||||||
bindings. Use the JSON Editor to modify definitions.
|
project JSON by hand. Valid edits synchronize with the canonical
|
||||||
The <strong>Test Action</strong> button calls the backend proxy
|
document immediately. <strong>Test Action</strong> calls the backend
|
||||||
directly. Diagnostics highlight broken references and missing wiring.
|
proxy with the configured action; diagnostics highlight invalid
|
||||||
|
definitions and broken references.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -839,7 +979,21 @@ function ActionInspector(): React.ReactElement {
|
|||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<span className={styles.sectionTitle}>REST Actions</span>
|
<span className={styles.sectionTitle}>REST Actions</span>
|
||||||
<span className={styles.sectionCount}>{actions.length}</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>
|
</div>
|
||||||
|
|
||||||
{/* Summary banner */}
|
{/* Summary banner */}
|
||||||
@ -856,17 +1010,34 @@ function ActionInspector(): React.ReactElement {
|
|||||||
|
|
||||||
{actions.length === 0 ? (
|
{actions.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
No REST actions defined. Add actions to{' '}
|
No REST actions defined. Add one to begin configuring the canonical
|
||||||
<code>project.actions</code> in the JSON Editor.
|
project document visually.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
actions.map((action) => (
|
actions.map((action, actionIndex) => (
|
||||||
<ActionCard
|
<React.Fragment key={`${action.id}-${actionIndex}`}>
|
||||||
key={action.id}
|
<ActionCard
|
||||||
action={action}
|
action={action}
|
||||||
diags={diagMap[action.id] ?? []}
|
diags={diagMap[actionDiagnosticKey(actionIndex)] ?? []}
|
||||||
allComponents={allComponents}
|
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>
|
</div>
|
||||||
|
|||||||
479
frontend/src/components/ActionInspector/RestActionEditor.tsx
Normal file
479
frontend/src/components/ActionInspector/RestActionEditor.tsx
Normal 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;
|
||||||
@ -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}}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
429
frontend/src/components/ActionInspector/actionEditorUtils.ts
Normal file
429
frontend/src/components/ActionInspector/actionEditorUtils.ts
Normal 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]));
|
||||||
|
}
|
||||||
57
frontend/src/components/Preview/buttonEvent.test.tsx
Normal file
57
frontend/src/components/Preview/buttonEvent.test.tsx
Normal 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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
frontend/src/components/VisualEditor/ButtonEventEditor.tsx
Normal file
30
frontend/src/components/VisualEditor/ButtonEventEditor.tsx
Normal 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 & Bindings first.</div>}
|
||||||
|
{missingReference && <div role="alert" style={{ fontSize: 10, color: '#b91c1c' }}>
|
||||||
|
This onClick event references missing action "{actionId}". Select a valid action or clear it.
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import ProjectToolbar from '../ProjectToolbar/ProjectToolbar';
|
|||||||
import { useProject } from '../../context/ProjectContext';
|
import { useProject } from '../../context/ProjectContext';
|
||||||
import styles from './VisualEditor.module.css';
|
import styles from './VisualEditor.module.css';
|
||||||
import type { ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
|
import type { ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
|
||||||
|
import ButtonEventEditor from './ButtonEventEditor';
|
||||||
|
|
||||||
// ── Dropdown options row editor ───────────────────────────────────────────────
|
// ── Dropdown options row editor ───────────────────────────────────────────────
|
||||||
|
|
||||||
@ -290,6 +291,7 @@ function VisualEditor(): React.ReactElement {
|
|||||||
updateComponentProperty,
|
updateComponentProperty,
|
||||||
updateComponentName,
|
updateComponentName,
|
||||||
updateComponentSize,
|
updateComponentSize,
|
||||||
|
updateComponentEvents,
|
||||||
} = canvas;
|
} = canvas;
|
||||||
|
|
||||||
const selectedComponent = activePage.components.find((x) => x.id === selectedId) ?? null;
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className={styles.infoEmpty}>No component selected</p>
|
<p className={styles.infoEmpty}>No component selected</p>
|
||||||
|
|||||||
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
17
frontend/src/components/VisualEditor/componentEventUtils.ts
Normal file
17
frontend/src/components/VisualEditor/componentEventUtils.ts
Normal 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] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
|||||||
import type {
|
import type {
|
||||||
ProjectDocument,
|
ProjectDocument,
|
||||||
CanvasComponent,
|
CanvasComponent,
|
||||||
|
ComponentEvent,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
Page,
|
Page,
|
||||||
} from '../types/project';
|
} from '../types/project';
|
||||||
@ -64,6 +65,7 @@ export type CanvasActions = {
|
|||||||
updateComponentProperty: (id: string, key: string, value: unknown) => void;
|
updateComponentProperty: (id: string, key: string, value: unknown) => void;
|
||||||
updateComponentName: (id: string, name: string) => void;
|
updateComponentName: (id: string, name: string) => void;
|
||||||
updateComponentSize: (id: string, width: number, height: number) => void;
|
updateComponentSize: (id: string, width: number, height: number) => void;
|
||||||
|
updateComponentEvents: (id: string, events: ComponentEvent[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCanvasActions(
|
export function useCanvasActions(
|
||||||
@ -199,6 +201,20 @@ export function useCanvasActions(
|
|||||||
[updatePage],
|
[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 {
|
return {
|
||||||
activePage,
|
activePage,
|
||||||
addComponent,
|
addComponent,
|
||||||
@ -207,5 +223,6 @@ export function useCanvasActions(
|
|||||||
updateComponentProperty,
|
updateComponentProperty,
|
||||||
updateComponentName,
|
updateComponentName,
|
||||||
updateComponentSize,
|
updateComponentSize,
|
||||||
|
updateComponentEvents,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,8 +100,9 @@ export type ComponentEvent = {
|
|||||||
/** ID of the project-level action to execute when this event fires. */
|
/** ID of the project-level action to execute when this event fires. */
|
||||||
actionId: string;
|
actionId: string;
|
||||||
/**
|
/**
|
||||||
* Maps action input parameter names to runtime value expressions.
|
* Reserved legacy field retained for schema compatibility.
|
||||||
* Not used in Step 15 (anonymous execution only).
|
* Preview does not consume inputMap. Request inputs use templates directly
|
||||||
|
* in RestAction url, headers, queryParameters, and bodyTemplate.
|
||||||
*/
|
*/
|
||||||
inputMap?: Record<string, string>;
|
inputMap?: Record<string, string>;
|
||||||
};
|
};
|
||||||
@ -270,6 +271,8 @@ export type Page = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
components: CanvasComponent[];
|
components: CanvasComponent[];
|
||||||
|
/** Page-level lifecycle event handlers (for example, onLoad → actionId). */
|
||||||
|
events?: ComponentEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
|
|||||||
@ -476,7 +476,7 @@
|
|||||||
},
|
},
|
||||||
"inputMap": {
|
"inputMap": {
|
||||||
"type": "object",
|
"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" },
|
"additionalProperties": { "type": "string" },
|
||||||
"default": {}
|
"default": {}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user