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.
|
||||
|
||||
|
||||
## Slice 2 Increment 1 Validation
|
||||
|
||||
Validation completed on 2026-07-18 for visual anonymous REST action authoring.
|
||||
|
||||
### Focused editor tests
|
||||
|
||||
Command:
|
||||
|
||||
```bash
|
||||
npm test -- --watchAll=false --runInBand --runTestsByPath \
|
||||
src/components/ActionInspector/actionEditorUtils.test.ts \
|
||||
src/components/ActionInspector/ActionInspector.test.tsx
|
||||
```
|
||||
|
||||
Result: Passed.
|
||||
|
||||
- Test suites: 2 passed, 2 total
|
||||
- Tests: 23 passed, 23 total
|
||||
- Covers canonical CRUD, duplicate-ID safety, reference discovery, request-map and URL validation, anonymous duplication, immediate document synchronization, and invalid local drafts.
|
||||
|
||||
### Full target-toolchain checks
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
npx --yes --package=node@20 --package=npm@10 npm test -- --watchAll=false --runInBand
|
||||
npx --yes --package=node@20 --package=npm@10 npm run build
|
||||
```
|
||||
|
||||
The frontend commands ran from `frontend/`. The build command also ran from `backend/`.
|
||||
|
||||
Results:
|
||||
|
||||
- Frontend tests: 9 suites and 456 tests passed; 0 snapshots.
|
||||
- Frontend production build: passed.
|
||||
- Backend TypeScript build: passed.
|
||||
|
||||
### Schema fixture matrix
|
||||
|
||||
Each fixture was checked with:
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d <fixture> \
|
||||
--spec=draft2020
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- 13 of 13 `valid-*.json` fixtures passed, including `valid-visual-rest-actions.json`.
|
||||
- 2 of 2 schema-invalid fixtures failed as expected.
|
||||
- 2 of 2 diagnostic-invalid fixtures passed structural validation as designed.
|
||||
|
||||
### Standalone frontend TypeScript check
|
||||
|
||||
Command:
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Result: Blocked by an existing dependency/toolchain mismatch. Frontend TypeScript 4.9 cannot parse syntax in resolved `@types/node@26.1.0` (first error: `node_modules/@types/node/ffi.d.ts(94,21): TS1139`). The CRA production build performs the application TypeScript compile and passed.
|
||||
|
||||
### Visual QA
|
||||
|
||||
The frontend development server compiled successfully at `http://localhost:3000`. In-app browser automation could not start during implementation while Docker Desktop was not running; its Node REPL kernel reported `helper_unknown_error: setup refresh had errors` during Windows sandbox setup.
|
||||
|
||||
After Docker Desktop and the app were started, the user completed the Slice 2 Increment 1 manual acceptance on 2026-07-19. The accepted workflow covered visual anonymous REST action authoring, a Button `events` entry inserted through canonical JSON for an `onClick` action reference, and referenced-action deletion confirmation. Full Slice 2 workflow-launcher and dependent-data acceptance remains pending later increments.
|
||||
|
||||
|
||||
## Runtime Startup
|
||||
|
||||
Initial backend startup against a clean SQLite database failed with:
|
||||
@ -112,3 +183,7 @@ Passing for the current automated and Docker smoke-test scope:
|
||||
- [x] Docker Compose build and integration checks pass
|
||||
|
||||
Remaining release work includes broader backend tests, browser end-to-end tests, security regression tests, and resolution of the frontend dependency audit findings.
|
||||
|
||||
## Slice 2 Increment 2 Button Event Validation
|
||||
|
||||
On 2026-07-19, focused tests passed (3 suites / 6 tests), full frontend tests passed (12 suites / 462 tests), the frontend and backend builds passed, and the schema matrix passed at 13 / 2 / 2. `ComponentEvent.inputMap` was documented as compatibility-only; executed request inputs continue to use component/variable REST templates. Automated Preview dispatch passed. The user then manually verified creating a REST action and assigning it visually to a Button; Increment 2 Button `onClick` acceptance passed.
|
||||
|
||||
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 action model supporting GET, POST, PUT, PATCH, and DELETE
|
||||
- Visual anonymous REST action creation, editing, duplication, and reference-aware deletion in Actions & Bindings
|
||||
- Progressive request controls for endpoint URL, headers, query parameters, static path parameters, and request bodies
|
||||
- Valid action edits synchronize immediately to the canonical document; invalid form drafts stay local
|
||||
- Anonymous server-side REST execution
|
||||
- Request URL, header, query, path, and body templates
|
||||
- Normalized proxy response envelope
|
||||
@ -101,7 +104,7 @@ All eleven schema-supported MVP components are available in the palette and have
|
||||
- Secure credential storage and secret resolution are not implemented.
|
||||
- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation.
|
||||
- Sanitized execution history and troubleshooting views are not implemented.
|
||||
- Visual REST action, event, and binding configuration workflows are incomplete; advanced configuration still relies on JSON editing.
|
||||
- Visual REST action authoring is implemented; component events, request inputs, response bindings, variables, and page-load actions still rely on JSON editing.
|
||||
- Project documents are validated in the JSON Editor, but save operations still need an unconditional validation gate.
|
||||
- Backend and end-to-end automated test coverage remains incomplete.
|
||||
- IBM Bob/watsonx will not be used; AI is provider-neutral and post-MVP.
|
||||
@ -117,8 +120,10 @@ The initial repository push was completed on 2026-07-18.
|
||||
- The working tree was clean immediately after the push.
|
||||
- All `:Zone.Identifier` sidecar files were removed before the initial commit.
|
||||
- Node 20/npm 10 clean installs pass after repairing the frontend lockfile.
|
||||
- Frontend validation passes: 7 suites and 433 tests, plus the production build.
|
||||
- Backend build and the schema fixture matrix pass.
|
||||
- Slice 2 Increment 1 frontend validation passes under Node 20/npm 10: 9 suites and 456 tests, plus the production build.
|
||||
- Backend build and the schema fixture matrix pass: 13 valid fixtures, 2 expected schema failures, and 2 diagnostic-invalid fixtures that remain structurally valid.
|
||||
- Standalone frontend `tsc --noEmit` is blocked by the existing TypeScript 4.9 / `@types/node@26.1.0` dependency mismatch; the CRA TypeScript production compile passes.
|
||||
- In-app browser automation could not start during implementation while Docker Desktop was not running. After Docker Desktop and the app were started, the user manually accepted Slice 2 Increment 1 on 2026-07-19, including a Button `onClick` event inserted through canonical JSON and reference-aware action deletion.
|
||||
- Docker Compose builds and starts both services.
|
||||
- Health, CRUD, cleanup, and restart persistence pass.
|
||||
- See `BASELINE.md` for exact evidence and remaining release work.
|
||||
@ -128,12 +133,13 @@ The initial repository push was completed on 2026-07-18.
|
||||
|
||||
Unless the user chooses a different priority, proceed in this order:
|
||||
|
||||
1. Add visual REST action, event, and binding configuration.
|
||||
2. Add validation to every project save path.
|
||||
3. Implement authentication and secure secret handling.
|
||||
4. Harden the REST proxy and add sanitized execution logging.
|
||||
5. Add backend, end-to-end, and security regression tests.
|
||||
6. Reconcile and consolidate project documentation.
|
||||
1. Add visual component-event configuration, beginning with Button `onClick`, then request input mapping.
|
||||
2. Add visual response bindings, variables, and page-load action configuration.
|
||||
3. Add validation to every project save path.
|
||||
4. Implement authentication and secure secret handling.
|
||||
5. Harden the REST proxy and add sanitized execution logging.
|
||||
6. Add backend, end-to-end, and security regression tests.
|
||||
7. Reconcile and consolidate project documentation.
|
||||
|
||||
See `TASKS.md` for the complete actionable checklist.
|
||||
|
||||
@ -172,12 +178,26 @@ After material work:
|
||||
|
||||
## Latest Handoff
|
||||
|
||||
Date: 2026-07-18
|
||||
Date: 2026-07-19
|
||||
|
||||
- Completed Slice 1 by implementing Text Area, Checkbox, Radio Group, Status Panel, and Container/Card across the palette, canvas, property editor, and Preview.
|
||||
- Added runtime value and response-binding support while keeping ephemeral values out of canonical JSON.
|
||||
- Scoped Container/Card to a flat presentational title/body contract for MVP; nesting remains post-MVP.
|
||||
- Added `valid-mvp-components.json` and focused renderer/binding tests.
|
||||
- Validation passes: 7 frontend suites / 433 tests, frontend production build, backend TypeScript build, and the new schema fixture.
|
||||
- User completed the full manual Slice 1 checklist successfully.
|
||||
- Next recommended action: begin `SLICE2.md`.
|
||||
- Completed Slice 2 Increment 1: visual anonymous REST action creation, editing, duplication, and reference-aware deletion.
|
||||
- Added method, URL, header, query, static path-parameter, and body controls with immediate canonical-document synchronization.
|
||||
- Kept invalid drafts and all execution state outside canonical JSON; new and duplicated actions are anonymous and omit deprecated `action.responseMapping`.
|
||||
- Added focused validation for action IDs, URLs, duplicate IDs, header names, unsupported path-parameter templates, and referenced deletion.
|
||||
- Added `valid-visual-rest-actions.json` and 23 focused tests.
|
||||
- Validation passes under Node 20/npm 10: 9 frontend suites / 456 tests, frontend production build, backend TypeScript build, and the 13 / 2 / 2 schema matrix.
|
||||
- The user manually accepted the Increment 1 REST action authoring and referenced-action deletion workflow after starting Docker Desktop and the app.
|
||||
- Standalone frontend `tsc --noEmit` remains blocked by the existing dependency mismatch. Browser automation remained unavailable; user manual acceptance passed.
|
||||
- No commit or push was made; the work remains uncommitted on top of `b9697cc` as requested.
|
||||
- Next recommended action: Slice 2 Increment 2, starting with visual Button `onClick` action selection and request inputs.
|
||||
|
||||
### Increment 2 Addendum
|
||||
|
||||
- Completed visual Button `onClick` action selection with no-action, add/change/clear, unrelated-event preservation, and missing-action diagnostics.
|
||||
- Canonical `component.events` updates immediately; automated coverage confirms Preview executes the selected REST action.
|
||||
- Reconciled request inputs: `ComponentEvent.inputMap` is compatibility-only and is not consumed. Request-input UI must write executed REST request templates.
|
||||
- Updated schema/type documentation and the representative visual REST fixture.
|
||||
- Validation passes: 12 frontend suites / 462 tests, frontend production build, backend build, and the 13 / 2 / 2 schema matrix.
|
||||
- Standalone frontend `tsc --noEmit` retains the recorded dependency failure.
|
||||
- Automated browser validation was unavailable. The user manually accepted the Increment 2 Button `onClick` workflow on 2026-07-19 by creating a REST action and assigning it visually to a Button.
|
||||
- No commit or push was made.
|
||||
|
||||
57
SLICE2.md
57
SLICE2.md
@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Not started
|
||||
In progress — Increment 2 Button `onClick` vertical increment complete; request-input UI next
|
||||
|
||||
## Objective
|
||||
|
||||
@ -27,18 +27,30 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Design a consistent workflow for editing actions, events, bindings, and variables.
|
||||
- [ ] Implement REST Action list, create, edit, duplicate, and delete operations.
|
||||
- [ ] Add method, URL, headers, query, path, body, timeout, and authentication-type controls.
|
||||
- [ ] Implement component event configuration, beginning with Button `onClick`.
|
||||
- [ ] Implement request input mapping from components and variables.
|
||||
- [x] Design a consistent, progressively disclosed workflow for editing actions, events, bindings, variables, and page-load behavior.
|
||||
- [x] Implement REST Action list, create, edit, duplicate, and reference-aware delete operations.
|
||||
- [x] Add method, URL, headers, query, path, and body controls for anonymous REST actions.
|
||||
- [ ] Decide and implement the canonical timeout field before exposing a timeout control; the current schema and runtime use a fixed backend timeout.
|
||||
- [x] Keep Slice 2 authentication authoring anonymous-only and defer credential-backed authentication controls to Slice 3.
|
||||
- [x] Implement component event configuration for Button `onClick`, including add/change/clear and dangling-reference diagnostics.
|
||||
- [ ] Implement request input authoring through executed REST request templates; do not write inert `ComponentEvent.inputMap`.
|
||||
- [ ] Implement response source and component/variable target selection.
|
||||
- [ ] Default new action-response bindings to `onSuccess`.
|
||||
- [ ] Implement page-load action configuration for initial data population.
|
||||
- [ ] Add variable declaration and default-value editing.
|
||||
- [ ] Warn before deleting referenced actions, variables, or components.
|
||||
- [ ] Ensure every visual edit immediately updates canonical JSON.
|
||||
- [ ] Add tests, examples, and documentation updates.
|
||||
- [ ] Warn before deleting referenced actions, variables, or components. Action deletion is covered; variable and component deletion remain.
|
||||
- [x] Ensure every REST action visual edit immediately updates canonical JSON while invalid drafts remain local.
|
||||
- [ ] Add slice-wide tests, examples, and documentation updates. Increment 1 coverage and a representative REST fixture are complete.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **REST action authoring:** Add canonical action CRUD, method/URL/request controls, local validation, diagnostics, reference-aware deletion warnings, focused tests, and a representative example project.
|
||||
2. **Component events and request inputs:** Configure Button `onClick` first, then supported `onChange` events and component/variable request templates.
|
||||
3. **Response bindings and variables:** Author top-level `project.bindings` with `onSuccess` defaults, supported response paths and targets, plus variable declarations/defaults.
|
||||
4. **Page-load actions:** Add the minimum page lifecycle contract needed to execute configured `onLoad` actions without introducing general orchestration.
|
||||
5. **Slice acceptance and hardening:** Complete dangling-reference diagnostics, persistence/JSON synchronization coverage, representative examples, builds, schema validation, and manual workflows.
|
||||
|
||||
Each increment writes only canonical configuration into the shared project document. Preview execution values, loading flags, responses, errors, and variable values remain ephemeral runtime state.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@ -50,9 +62,11 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
|
||||
|
||||
## Validation
|
||||
|
||||
- [ ] Frontend TypeScript check and production build pass.
|
||||
- [ ] Editor interaction tests pass.
|
||||
- [ ] Generated canonical JSON validates against the schema.
|
||||
- [ ] Standalone frontend TypeScript check passes. TypeScript 4.9 currently cannot parse the resolved `@types/node@26.1.0`; the CRA production compile passes.
|
||||
- [x] Frontend production build passes through the Button `onClick` increment.
|
||||
- [x] Editor interaction and Preview dispatch tests pass through the Button `onClick` increment.
|
||||
- [x] Increment 2 representative canonical JSON validates against the schema.
|
||||
- [x] Increment 1 manual REST action authoring and referenced-action deletion workflow passes.
|
||||
- [ ] Manual workflow-launcher and dependent-data scenarios pass.
|
||||
|
||||
## Risks and Open Questions
|
||||
@ -63,10 +77,21 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
|
||||
|
||||
## Progress Log
|
||||
|
||||
No work recorded yet.
|
||||
- 2026-07-18: Restored project state from `CODEX.md`, `TASKS.md`, `MVP_SCOPE.md`, and this plan.
|
||||
- Verified the clean `main` branch against a freshly fetched `origin/main`; both point to `b9697cc8b11bce806887d6e7a29098a308b0846a`.
|
||||
- Re-ran the pre-change frontend baseline successfully: 7 suites and 433 tests passed.
|
||||
- Audited the canonical action/event/binding model, Visual Editor, read-only Actions & Bindings Inspector, Preview execution path, schema validation, examples, and existing tests.
|
||||
- Chose visual REST action authoring as the first coherent vertical increment.
|
||||
- Completed canonical anonymous REST action creation, editing, duplication, and reference-aware deletion in Actions & Bindings.
|
||||
- Added progressive request controls for method, endpoint URL, headers, query parameters, static path parameters, and request body; invalid drafts remain local and runtime state remains separate.
|
||||
- Added action-definition, duplicate-ID, header-name, and path-template diagnostics plus referenced-deletion warnings.
|
||||
- Added 23 focused tests plus `valid-visual-rest-actions.json`.
|
||||
- Validation passed under Node 20/npm 10: 9 frontend suites / 456 tests, frontend production build, backend TypeScript build, and the 13-valid / 2-schema-invalid / 2-diagnostic-invalid schema matrix.
|
||||
- Standalone `tsc --noEmit` remains blocked by the existing TypeScript 4.9 / `@types/node@26.1.0` dependency mismatch; the application production build compiles successfully.
|
||||
- In-app browser automation could not start during implementation while Docker Desktop was not running. After Docker Desktop and the app were started, the user manually accepted Increment 1 on 2026-07-19, including a Button `onClick` event inserted through canonical JSON and the referenced-action deletion workflow.
|
||||
|
||||
## Handoff
|
||||
|
||||
- Last completed: Slice plan created.
|
||||
- Next action: Design the action editor data flow and acceptance-test project.
|
||||
- Known blockers: Coordinate secret-reference fields with Slice 3.
|
||||
- Last completed: Increment 2 visual Button `onClick` configuration, automated validation, and user manual acceptance.
|
||||
- Next action: Add request-input UI that writes executed REST templates for component and variable values.
|
||||
- Known blockers: None for request-input implementation. Standalone frontend `tsc` retains its recorded tooling limitation.
|
||||
|
||||
4
TASKS.md
4
TASKS.md
@ -70,8 +70,8 @@ This document summarizes the current implementation and the remaining work requi
|
||||
|
||||
### Visual configuration workflows
|
||||
|
||||
- [ ] Implement a visual REST Action editor for creating, editing, and deleting actions without hand-editing JSON.
|
||||
- [ ] Implement visual component-event configuration, including binding a button to an action.
|
||||
- [x] Implement a visual REST Action editor for creating, editing, duplicating, and deleting anonymous actions without hand-editing JSON.
|
||||
- [x] Implement visual Button `onClick` configuration with add/change/clear, missing-reference diagnostics, canonical synchronization, and Preview execution.
|
||||
- [ ] Implement a visual project-binding editor for request inputs and response targets.
|
||||
- [ ] Implement page-load action configuration for automatically populated components.
|
||||
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
|
||||
|
||||
@ -110,14 +110,18 @@ Allowed `type` values:
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_trigger_workflow",
|
||||
"inputMap": { "workflowId": "components.workflowIdInput.value" }
|
||||
"actionId": "action_trigger_workflow"
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
}
|
||||
```
|
||||
|
||||
`ComponentEvent.inputMap` is retained in schema version `0.1.0` for compatibility
|
||||
but is not consumed by Preview. Request values are configured in REST action
|
||||
templates using `{{components.<name>.value}}` or `{{variables.<name>}}`.
|
||||
Visual request-input authoring must write those executed request fields.
|
||||
|
||||
### `Action` (REST Action)
|
||||
|
||||
A REST API call definition. Required fields: `id`, `name`, `method`, `url`, `authenticationType`.
|
||||
@ -144,10 +148,18 @@ Allowed `authenticationType` values: `anonymous` · `bearerToken` · `basicAuth`
|
||||
"workflowId": "{{workflowId}}"
|
||||
},
|
||||
"bodyTemplate": "{\"params\": {{params}}}",
|
||||
"authenticationType": "bearerToken",
|
||||
"responseMapping": [
|
||||
{ "source": "data.status", "target": "variables.lastStatus" }
|
||||
]
|
||||
"authenticationType": "bearerToken"
|
||||
}
|
||||
```
|
||||
|
||||
New response mappings belong in top-level `project.bindings`, not inside the action:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "bind_trigger_status",
|
||||
"source": "actions.action_trigger_workflow.response.body.data.status",
|
||||
"target": "variables.lastStatus",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
```
|
||||
|
||||
@ -167,7 +179,7 @@ Allowed `authenticationType` values: `anonymous` · `bearerToken` · `basicAuth`
|
||||
| `pathParameters` | `object` | — | Path segment substitutions. Keys match `{{paramName}}` in the URL. |
|
||||
| `bodyTemplate` | `string` | — | Request body template. Use `{{variableName}}` for runtime substitutions. |
|
||||
| `authenticationType` | `string` | ✅ | Authentication strategy (see allowed values above). Credentials are never stored here. |
|
||||
| `responseMapping` | `array` | — | Rules mapping response fields to component properties or variables. |
|
||||
| `responseMapping` | `array` | — | Deprecated legacy action-local mappings. New mappings use top-level `project.bindings`. |
|
||||
|
||||
### `Binding`
|
||||
|
||||
@ -220,6 +232,7 @@ Optional canvas and display configuration.
|
||||
| `examples/project-definitions/valid-minimal.json` | Smallest valid project definition (one empty page). |
|
||||
| `examples/project-definitions/valid-full.json` | Full example: Concert Workflow Launcher with all features. |
|
||||
| `examples/project-definitions/valid-rest-actions.json` | Showcases all five authentication types across seven REST actions. No canvas components — validates the action model in isolation. |
|
||||
| `examples/project-definitions/valid-visual-rest-actions.json` | Slice 2 visual-authoring fixture covering anonymous GET/POST request fields. |
|
||||
| `examples/project-definitions/invalid-missing-required.json` | Intentionally invalid document showing schema errors. |
|
||||
|
||||
---
|
||||
|
||||
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;
|
||||
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
|
||||
*
|
||||
* Read-only view of REST actions and bindings from the current project,
|
||||
* read directly from ProjectContext.
|
||||
* Visual REST action authoring plus binding inspection for the current project,
|
||||
* synchronized through the canonical document in ProjectContext.
|
||||
*
|
||||
* ── Diagnostics computed ──────────────────────────────────────────────────────
|
||||
* Per action (Step 15.6):
|
||||
@ -35,7 +35,7 @@
|
||||
* • Binding targeting Table.rows / Table.value (warning)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import { executeAction } from '../../api/proxyApi';
|
||||
import type { ProxyResponse } from '../../api/proxyApi';
|
||||
@ -49,6 +49,18 @@ import {
|
||||
parseVariableTargetPath,
|
||||
} from '../Preview/bindingUtils';
|
||||
import styles from './ActionInspector.module.css';
|
||||
import RestActionEditor from './RestActionEditor';
|
||||
import {
|
||||
appendRestAction,
|
||||
buildDeleteConfirmation,
|
||||
collectReferencedActionIds,
|
||||
createRestAction,
|
||||
duplicateRestAction,
|
||||
findActionReferences,
|
||||
removeRestActionAt,
|
||||
replaceRestActionAt,
|
||||
validateRestAction,
|
||||
} from './actionEditorUtils';
|
||||
|
||||
// ── Diagnostic types ──────────────────────────────────────────────────────────
|
||||
|
||||
@ -60,20 +72,24 @@ type Diagnostic = {
|
||||
};
|
||||
|
||||
/**
|
||||
* All diagnostics indexed by their subject ID so cards can look up their own.
|
||||
* Keys: action IDs, binding IDs.
|
||||
* All diagnostics indexed by a stable render subject so cards can look up their own.
|
||||
* Keys: action indices (isolating duplicate IDs), binding IDs, and synthetic subjects.
|
||||
* Also a special key "__componentEvents" for component-event issues not tied to
|
||||
* a specific binding (currently unused — shown on binding cards that reference
|
||||
* the missing action).
|
||||
*/
|
||||
type DiagMap = Record<string, Diagnostic[]>;
|
||||
|
||||
function actionDiagnosticKey(actionIndex: number): string {
|
||||
return `__action_${actionIndex}`;
|
||||
}
|
||||
|
||||
// ── Pure diagnostic computation ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes all diagnostics from the project in a single pass.
|
||||
* Never throws — any unexpected input is handled gracefully.
|
||||
* Returns a DiagMap keyed by actionId or bindingId.
|
||||
* Returns a DiagMap keyed by action index, binding ID, or synthetic subject.
|
||||
*/
|
||||
function computeDiagnostics(
|
||||
actions: RestAction[],
|
||||
@ -115,11 +131,16 @@ function computeDiagnostics(
|
||||
|
||||
// ── 1. Diagnostics per action ─────────────────────────────────────────────
|
||||
|
||||
for (const action of actions) {
|
||||
actions.forEach((action, actionIndex) => {
|
||||
const diagnosticKey = actionDiagnosticKey(actionIndex);
|
||||
for (const issue of validateRestAction(action, actions)) {
|
||||
add(diagnosticKey, issue.severity, issue.message);
|
||||
}
|
||||
|
||||
// 1a. Untriggered action
|
||||
if (!triggeredActionIds.has(action.id)) {
|
||||
add(
|
||||
action.id,
|
||||
diagnosticKey,
|
||||
'info',
|
||||
`Action "${action.name}" is not triggered by any component event. ` +
|
||||
`Add an events entry on a component: { "event": "onClick", "actionId": "${action.id}" }`,
|
||||
@ -136,7 +157,7 @@ function computeDiagnostics(
|
||||
// runtime does not interpolate pathParameters values at all.
|
||||
if (token.location.startsWith('pathParameters.')) {
|
||||
add(
|
||||
action.id,
|
||||
diagnosticKey,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} uses a variable placeholder ` +
|
||||
`in a path-parameter value. Variable interpolation is not supported in ` +
|
||||
@ -147,7 +168,7 @@ function computeDiagnostics(
|
||||
// but use a separate, clearly scoped message.
|
||||
if (!declaredVariableNames.has(token.name)) {
|
||||
add(
|
||||
action.id,
|
||||
diagnosticKey,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} also references variable ` +
|
||||
`"${token.name}" which is not declared in project.variables.`,
|
||||
@ -159,7 +180,7 @@ function computeDiagnostics(
|
||||
// Variable token in a supported location — check declaration (Step 18.1)
|
||||
if (!declaredVariableNames.has(token.name)) {
|
||||
add(
|
||||
action.id,
|
||||
diagnosticKey,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} references variable ` +
|
||||
`"${token.name}" which is not declared in project.variables.`,
|
||||
@ -171,7 +192,7 @@ function computeDiagnostics(
|
||||
// Component token — check property and existence
|
||||
if (token.propertyName !== 'value') {
|
||||
add(
|
||||
action.id,
|
||||
diagnosticKey,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} references property ` +
|
||||
`"${token.propertyName ?? '?'}". Only "value" is supported. ` +
|
||||
@ -182,7 +203,7 @@ function computeDiagnostics(
|
||||
// Missing component
|
||||
if (token.componentName && !componentsByName.has(token.componentName)) {
|
||||
add(
|
||||
action.id,
|
||||
diagnosticKey,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} references component ` +
|
||||
`"${token.componentName}" which does not exist on any page.`,
|
||||
@ -193,7 +214,7 @@ function computeDiagnostics(
|
||||
for (const bad of malformed) {
|
||||
const isMalformedVar = classifyVariableExpression(bad.raw) === 'malformed';
|
||||
add(
|
||||
action.id,
|
||||
diagnosticKey,
|
||||
'warn',
|
||||
isMalformedVar
|
||||
? `Malformed variable template "${bad.raw}" at ${bad.location}: ` +
|
||||
@ -203,7 +224,7 @@ function computeDiagnostics(
|
||||
`missing closing "}}". Check your template syntax.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Index: component name → all components with that name (duplicate check) ─
|
||||
const componentsByNameAll = new Map<string, CanvasComponent[]>();
|
||||
@ -568,17 +589,43 @@ type ActionCardProps = {
|
||||
action: RestAction;
|
||||
diags: Diagnostic[];
|
||||
allComponents: CanvasComponent[];
|
||||
isEditing: boolean;
|
||||
actionsLocked: boolean;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function ActionCard({ action, diags, allComponents }: ActionCardProps): React.ReactElement {
|
||||
function ActionCard({
|
||||
action,
|
||||
diags,
|
||||
allComponents,
|
||||
isEditing,
|
||||
actionsLocked,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: ActionCardProps): React.ReactElement {
|
||||
const [test, setTest] = useState<TestState>({ status: 'idle' });
|
||||
const requestGeneration = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
requestGeneration.current += 1;
|
||||
setTest({ status: 'idle' });
|
||||
return () => {
|
||||
requestGeneration.current += 1;
|
||||
};
|
||||
}, [action]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
const generation = ++requestGeneration.current;
|
||||
setTest({ status: 'running' });
|
||||
try {
|
||||
const response = await executeAction(action);
|
||||
if (generation !== requestGeneration.current) return;
|
||||
setTest({ status: 'ok', response });
|
||||
} catch (err) {
|
||||
if (generation !== requestGeneration.current) return;
|
||||
setTest({
|
||||
status: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
@ -586,6 +633,8 @@ function ActionCard({ action, diags, allComponents }: ActionCardProps): React.Re
|
||||
}
|
||||
}, [action]);
|
||||
|
||||
const controlsLocked = actionsLocked || test.status === 'running';
|
||||
|
||||
// Compute template info for display
|
||||
const { tokens, malformed } = useMemo(
|
||||
() => extractTemplates(action),
|
||||
@ -662,11 +711,41 @@ function ActionCard({ action, diags, allComponents }: ActionCardProps): React.Re
|
||||
{/* ── Diagnostics ── */}
|
||||
<DiagList diags={diags} />
|
||||
|
||||
<div className={styles.actionControls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.smallButton}
|
||||
onClick={onEdit}
|
||||
disabled={controlsLocked}
|
||||
aria-label={`Edit REST action ${action.name}`}
|
||||
>
|
||||
{isEditing ? 'Editing' : 'Edit'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.smallButton}
|
||||
onClick={onDuplicate}
|
||||
disabled={controlsLocked}
|
||||
aria-label={`Duplicate REST action ${action.name}`}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dangerButton}
|
||||
onClick={onDelete}
|
||||
disabled={controlsLocked}
|
||||
aria-label={`Delete REST action ${action.name}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Test Action ── */}
|
||||
<div className={styles.testRow}>
|
||||
<button
|
||||
className={styles.testBtn}
|
||||
disabled={test.status === 'running'}
|
||||
disabled={test.status === 'running' || actionsLocked}
|
||||
onClick={handleTest}
|
||||
>
|
||||
{test.status === 'running' ? 'Running…' : 'Test Action'}
|
||||
@ -793,7 +872,7 @@ function BindingCard({
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
function ActionInspector(): React.ReactElement {
|
||||
const { doc } = useProject();
|
||||
const { doc, setDoc } = useProject();
|
||||
const { actions, bindings, pages, variables } = doc.project;
|
||||
|
||||
const allComponents = useMemo(
|
||||
@ -801,6 +880,60 @@ function ActionInspector(): React.ReactElement {
|
||||
[pages],
|
||||
);
|
||||
|
||||
const [editingActionIndex, setEditingActionIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
editingActionIndex !== null &&
|
||||
editingActionIndex >= actions.length
|
||||
) {
|
||||
setEditingActionIndex(null);
|
||||
}
|
||||
}, [actions.length, editingActionIndex]);
|
||||
|
||||
const handleAddAction = useCallback(() => {
|
||||
const action = createRestAction(
|
||||
actions,
|
||||
collectReferencedActionIds(doc),
|
||||
);
|
||||
setDoc((current) => appendRestAction(current, action));
|
||||
setEditingActionIndex(actions.length);
|
||||
}, [actions, doc, setDoc]);
|
||||
|
||||
const handleUpdateAction = useCallback(
|
||||
(actionIndex: number, action: RestAction) => {
|
||||
setDoc((current) => replaceRestActionAt(current, actionIndex, action));
|
||||
},
|
||||
[setDoc],
|
||||
);
|
||||
|
||||
const handleDuplicateAction = useCallback(
|
||||
(source: RestAction) => {
|
||||
const duplicate = duplicateRestAction(
|
||||
source,
|
||||
actions,
|
||||
collectReferencedActionIds(doc),
|
||||
);
|
||||
setDoc((current) => appendRestAction(current, duplicate));
|
||||
setEditingActionIndex(actions.length);
|
||||
},
|
||||
[actions, doc, setDoc],
|
||||
);
|
||||
|
||||
const handleDeleteAction = useCallback(
|
||||
(actionIndex: number, action: RestAction) => {
|
||||
const references = findActionReferences(doc, action.id);
|
||||
if (!window.confirm(buildDeleteConfirmation(action, references))) return;
|
||||
|
||||
setDoc((current) => removeRestActionAt(current, actionIndex));
|
||||
setEditingActionIndex((current) => {
|
||||
if (current === null || current === actionIndex) return null;
|
||||
return current > actionIndex ? current - 1 : current;
|
||||
});
|
||||
},
|
||||
[doc, setDoc],
|
||||
);
|
||||
|
||||
// Compute all diagnostics once per render cycle
|
||||
const diagMap = useMemo(
|
||||
() => computeDiagnostics(actions, bindings, allComponents, variables),
|
||||
@ -809,11 +942,17 @@ function ActionInspector(): React.ReactElement {
|
||||
|
||||
// Count total warnings/infos for section summary banners
|
||||
const actionWarnings = actions.reduce(
|
||||
(n, a) => n + (diagMap[a.id]?.filter((d) => d.severity === 'warn').length ?? 0),
|
||||
(count, _action, actionIndex) => count + (
|
||||
diagMap[actionDiagnosticKey(actionIndex)]
|
||||
?.filter((diagnostic) => diagnostic.severity === 'warn').length ?? 0
|
||||
),
|
||||
0,
|
||||
);
|
||||
const actionInfos = actions.reduce(
|
||||
(n, a) => n + (diagMap[a.id]?.filter((d) => d.severity === 'info').length ?? 0),
|
||||
(count, _action, actionIndex) => count + (
|
||||
diagMap[actionDiagnosticKey(actionIndex)]
|
||||
?.filter((diagnostic) => diagnostic.severity === 'info').length ?? 0
|
||||
),
|
||||
0,
|
||||
);
|
||||
const bindingWarnings = bindings.reduce(
|
||||
@ -828,10 +967,11 @@ function ActionInspector(): React.ReactElement {
|
||||
<div className={styles.pageHeader}>
|
||||
<div className={styles.pageTitle}>Actions & Bindings</div>
|
||||
<div className={styles.pageSubtitle}>
|
||||
Read-only inspector for the current project's REST actions and
|
||||
bindings. Use the JSON Editor to modify definitions.
|
||||
The <strong>Test Action</strong> button calls the backend proxy
|
||||
directly. Diagnostics highlight broken references and missing wiring.
|
||||
Create and configure anonymous REST actions here without editing
|
||||
project JSON by hand. Valid edits synchronize with the canonical
|
||||
document immediately. <strong>Test Action</strong> calls the backend
|
||||
proxy with the configured action; diagnostics highlight invalid
|
||||
definitions and broken references.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -839,7 +979,21 @@ function ActionInspector(): React.ReactElement {
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span className={styles.sectionTitle}>REST Actions</span>
|
||||
<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>
|
||||
|
||||
{/* Summary banner */}
|
||||
@ -856,17 +1010,34 @@ function ActionInspector(): React.ReactElement {
|
||||
|
||||
{actions.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No REST actions defined. Add actions to{' '}
|
||||
<code>project.actions</code> in the JSON Editor.
|
||||
No REST actions defined. Add one to begin configuring the canonical
|
||||
project document visually.
|
||||
</div>
|
||||
) : (
|
||||
actions.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
diags={diagMap[action.id] ?? []}
|
||||
allComponents={allComponents}
|
||||
/>
|
||||
actions.map((action, actionIndex) => (
|
||||
<React.Fragment key={`${action.id}-${actionIndex}`}>
|
||||
<ActionCard
|
||||
action={action}
|
||||
diags={diagMap[actionDiagnosticKey(actionIndex)] ?? []}
|
||||
allComponents={allComponents}
|
||||
isEditing={editingActionIndex === actionIndex}
|
||||
actionsLocked={editingActionIndex !== null}
|
||||
onEdit={() => setEditingActionIndex(actionIndex)}
|
||||
onDuplicate={() => handleDuplicateAction(action)}
|
||||
onDelete={() => handleDeleteAction(actionIndex, action)}
|
||||
/>
|
||||
{editingActionIndex === actionIndex && (
|
||||
<RestActionEditor
|
||||
key={`${action.id}-${actionIndex}`}
|
||||
action={action}
|
||||
actions={actions}
|
||||
onChange={(updatedAction) =>
|
||||
handleUpdateAction(actionIndex, updatedAction)
|
||||
}
|
||||
onDone={() => setEditingActionIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
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 styles from './VisualEditor.module.css';
|
||||
import type { ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
|
||||
import ButtonEventEditor from './ButtonEventEditor';
|
||||
|
||||
// ── Dropdown options row editor ───────────────────────────────────────────────
|
||||
|
||||
@ -290,6 +291,7 @@ function VisualEditor(): React.ReactElement {
|
||||
updateComponentProperty,
|
||||
updateComponentName,
|
||||
updateComponentSize,
|
||||
updateComponentEvents,
|
||||
} = canvas;
|
||||
|
||||
const selectedComponent = activePage.components.find((x) => x.id === selectedId) ?? null;
|
||||
@ -648,6 +650,13 @@ function VisualEditor(): React.ReactElement {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedComponent.type === 'Button' && (
|
||||
<ButtonEventEditor
|
||||
events={selectedComponent.events}
|
||||
actions={doc.project.actions}
|
||||
onChange={(events) => updateComponentEvents(selectedComponent.id, events)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.infoEmpty}>No component selected</p>
|
||||
|
||||
@ -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 {
|
||||
ProjectDocument,
|
||||
CanvasComponent,
|
||||
ComponentEvent,
|
||||
ComponentType,
|
||||
Page,
|
||||
} from '../types/project';
|
||||
@ -64,6 +65,7 @@ export type CanvasActions = {
|
||||
updateComponentProperty: (id: string, key: string, value: unknown) => void;
|
||||
updateComponentName: (id: string, name: string) => void;
|
||||
updateComponentSize: (id: string, width: number, height: number) => void;
|
||||
updateComponentEvents: (id: string, events: ComponentEvent[]) => void;
|
||||
};
|
||||
|
||||
export function useCanvasActions(
|
||||
@ -199,6 +201,20 @@ export function useCanvasActions(
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
const updateComponentEvents = useCallback(
|
||||
(id: string, events: ComponentEvent[]) => {
|
||||
updatePage((p) => ({
|
||||
...p,
|
||||
components: p.components.map((c) =>
|
||||
c.id === id
|
||||
? { ...c, ...(events.length > 0 ? { events } : { events: undefined }) }
|
||||
: c,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
return {
|
||||
activePage,
|
||||
addComponent,
|
||||
@ -207,5 +223,6 @@ export function useCanvasActions(
|
||||
updateComponentProperty,
|
||||
updateComponentName,
|
||||
updateComponentSize,
|
||||
updateComponentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
@ -100,8 +100,9 @@ export type ComponentEvent = {
|
||||
/** ID of the project-level action to execute when this event fires. */
|
||||
actionId: string;
|
||||
/**
|
||||
* Maps action input parameter names to runtime value expressions.
|
||||
* Not used in Step 15 (anonymous execution only).
|
||||
* Reserved legacy field retained for schema compatibility.
|
||||
* Preview does not consume inputMap. Request inputs use templates directly
|
||||
* in RestAction url, headers, queryParameters, and bodyTemplate.
|
||||
*/
|
||||
inputMap?: Record<string, string>;
|
||||
};
|
||||
@ -270,6 +271,8 @@ export type Page = {
|
||||
description?: string;
|
||||
order?: number;
|
||||
components: CanvasComponent[];
|
||||
/** Page-level lifecycle event handlers (for example, onLoad → actionId). */
|
||||
events?: ComponentEvent[];
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
|
||||
@ -476,7 +476,7 @@
|
||||
},
|
||||
"inputMap": {
|
||||
"type": "object",
|
||||
"description": "Maps action input parameter names to runtime value expressions (component refs or variable refs).",
|
||||
"description": "Reserved legacy field retained for compatibility. Preview request inputs use templates in REST action request fields; inputMap is not executed.",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"default": {}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user