Add visual response bindings and variables
This commit is contained in:
parent
6fd084c8b3
commit
9e4eb33e05
26
BASELINE.md
26
BASELINE.md
@ -193,3 +193,29 @@ On 2026-07-19, focused tests passed (3 suites / 6 tests), full frontend tests pa
|
|||||||
On 2026-07-19, focused request-input tests passed at 3 suites / 9 tests, the full frontend suite passed at 14 suites / 467 tests, frontend and backend builds passed, and the schema matrix passed at 13 / 2 / 2.
|
On 2026-07-19, focused request-input tests passed at 3 suites / 9 tests, the full frontend suite passed at 14 suites / 467 tests, frontend and backend builds passed, and the schema matrix passed at 13 / 2 / 2.
|
||||||
|
|
||||||
The UI writes executed component/variable templates to URL, header, query, and body fields while excluding path parameters and `inputMap`. Standalone `tsc` reproduced the recorded dependency mismatch. Manual request-input acceptance remains pending.
|
The UI writes executed component/variable templates to URL, header, query, and body fields while excluding path parameters and `inputMap`. Standalone `tsc` reproduced the recorded dependency mismatch. Manual request-input acceptance remains pending.
|
||||||
|
|
||||||
|
### Pending manual request-input acceptance
|
||||||
|
|
||||||
|
1. Start Docker Desktop and run Conductor through Docker Compose.
|
||||||
|
2. Open a project containing a Text Input or Dropdown, a Button, and an anonymous REST action.
|
||||||
|
3. Edit the action and add a query parameter such as `item`.
|
||||||
|
4. In Request value reference, choose the component, choose `Query: item`, and insert the reference.
|
||||||
|
5. Confirm canonical JSON immediately contains `"item": "{{components.<componentName>.value}}"` and does not add `inputMap`.
|
||||||
|
6. Assign the action to the Button, enter or select a Preview value, and execute it.
|
||||||
|
7. Confirm the outgoing request uses the runtime component value and loading, response, and error state remain ephemeral.
|
||||||
|
8. Confirm path parameters are not offered as destinations and raw URL/header/query/body template editing still works.
|
||||||
|
9. If an existing JSON-authored variable is available, repeat insertion and verify `{{variables.<name>}}` resolution.
|
||||||
|
|
||||||
|
## Slice 2 Response Binding and Variable Validation
|
||||||
|
|
||||||
|
On 2026-07-20, focused configuration tests passed under Node 20/npm 10 at 2 suites / 14 tests. The full frontend suite passed at 15 suites / 477 tests with 0 snapshots. The frontend CRA production compile and backend TypeScript build passed. The schema fixture matrix passed at 13 valid fixtures, 2 expected schema failures, and 2 diagnostic-invalid fixtures that remain structurally valid.
|
||||||
|
|
||||||
|
Automated coverage verifies canonical response-binding and variable creation/editing/deletion, the `onSuccess` default for new action-response bindings, supported and unambiguous target filtering, typed variable default parsing, canonical synchronization, binding-editor deletion lifecycle, and reference-aware variable deletion warnings. Existing legacy `onClick` compatibility remains in runtime and diagnostics and supports one-way visual migration; the UI cannot create a new `onClick` response binding. No deprecated `action.responseMapping` behavior was added.
|
||||||
|
|
||||||
|
Standalone frontend `npx tsc --noEmit` continues to fail in `node_modules/@types/node/ffi.d.ts` because the project's TypeScript 4.9 compiler cannot parse the resolved `@types/node@26.1.0` declarations. This is reported separately from the successful CRA production TypeScript compile.
|
||||||
|
|
||||||
|
Manual acceptance is pending. During the 2026-07-20 audit, the Docker CLI reported that it is unavailable in this WSL distro and Docker Desktop WSL integration must be enabled. Compose/service/health validation and the request-input browser checklist therefore could not be completed here. The earlier request-input workflow and the new response-binding/variable workflow have not been marked manually accepted.
|
||||||
|
|
||||||
|
### Partial manual request-input checkpoint
|
||||||
|
|
||||||
|
On 2026-07-20, the user confirmed that the `item` query-parameter row and guided Request value reference insertion into `Query: item` looked correct. This is a partial checkpoint, not full manual acceptance. Preview execution, outgoing-request inspection, ephemeral-state verification, path-destination exclusion, raw-template regression coverage, and variable reference resolution remain pending.
|
||||||
|
|||||||
31
CODEX.md
31
CODEX.md
@ -104,7 +104,7 @@ All eleven schema-supported MVP components are available in the palette and have
|
|||||||
- Secure credential storage and secret resolution are not implemented.
|
- Secure credential storage and secret resolution are not implemented.
|
||||||
- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation.
|
- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation.
|
||||||
- Sanitized execution history and troubleshooting views are not implemented.
|
- Sanitized execution history and troubleshooting views are not implemented.
|
||||||
- Visual REST action authoring is implemented; component events, request inputs, response bindings, variables, and page-load actions still rely on JSON editing.
|
- Visual REST action, Button event, request-input, response-binding, and variable authoring are implemented; page-load actions still rely on JSON editing.
|
||||||
- Project documents are validated in the JSON Editor, but save operations still need an unconditional validation gate.
|
- Project documents are validated in the JSON Editor, but save operations still need an unconditional validation gate.
|
||||||
- Backend and end-to-end automated test coverage remains incomplete.
|
- Backend and end-to-end automated test coverage remains incomplete.
|
||||||
- IBM Bob/watsonx will not be used; AI is provider-neutral and post-MVP.
|
- IBM Bob/watsonx will not be used; AI is provider-neutral and post-MVP.
|
||||||
@ -133,8 +133,8 @@ The initial repository push was completed on 2026-07-18.
|
|||||||
|
|
||||||
Unless the user chooses a different priority, proceed in this order:
|
Unless the user chooses a different priority, proceed in this order:
|
||||||
|
|
||||||
1. Add visual component-event configuration, beginning with Button `onClick`, then request input mapping.
|
1. Complete the pending manual acceptance for request-input authoring and the proportional response-binding/variable workflow.
|
||||||
2. Add visual response bindings, variables, and page-load action configuration.
|
2. Add visual page-load action configuration.
|
||||||
3. Add validation to every project save path.
|
3. Add validation to every project save path.
|
||||||
4. Implement authentication and secure secret handling.
|
4. Implement authentication and secure secret handling.
|
||||||
5. Harden the REST proxy and add sanitized execution logging.
|
5. Harden the REST proxy and add sanitized execution logging.
|
||||||
@ -178,7 +178,7 @@ After material work:
|
|||||||
|
|
||||||
## Latest Handoff
|
## Latest Handoff
|
||||||
|
|
||||||
Date: 2026-07-19
|
Date: 2026-07-20
|
||||||
|
|
||||||
- Completed Slice 2 Increment 1: visual anonymous REST action creation, editing, duplication, and reference-aware deletion.
|
- 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.
|
- Added method, URL, header, query, static path-parameter, and body controls with immediate canonical-document synchronization.
|
||||||
@ -188,8 +188,7 @@ Date: 2026-07-19
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Slice 2 REST action and Button event configuration was committed and pushed as `3994eaa`.
|
||||||
- Next recommended action: Slice 2 Increment 2, starting with visual Button `onClick` action selection and request inputs.
|
|
||||||
|
|
||||||
### Increment 2 Addendum
|
### Increment 2 Addendum
|
||||||
|
|
||||||
@ -200,7 +199,7 @@ Date: 2026-07-19
|
|||||||
- Validation passes: 12 frontend suites / 462 tests, frontend production build, backend build, and the 13 / 2 / 2 schema matrix.
|
- 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.
|
- 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.
|
- 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.
|
- Visual request-input authoring and its handoff were committed and pushed as `6fd084c`.
|
||||||
|
|
||||||
### Request Input Addendum
|
### Request Input Addendum
|
||||||
|
|
||||||
@ -211,3 +210,21 @@ Date: 2026-07-19
|
|||||||
- Validation passes: 14 frontend suites / 467 tests, frontend production build, backend build, and the 13 / 2 / 2 schema matrix.
|
- Validation passes: 14 frontend suites / 467 tests, frontend production build, backend build, and the 13 / 2 / 2 schema matrix.
|
||||||
- Standalone frontend `tsc --noEmit` retains the recorded dependency failure.
|
- Standalone frontend `tsc --noEmit` retains the recorded dependency failure.
|
||||||
- Manual request-input acceptance remains pending.
|
- Manual request-input acceptance remains pending.
|
||||||
|
- `NEXT_SESSION_PROMPT.md` contains the continuation scope and manual request-input acceptance checklist.
|
||||||
|
|
||||||
|
### Response Binding and Variable Addendum
|
||||||
|
|
||||||
|
- Added visual create, edit, and delete support for canonical top-level `project.bindings`.
|
||||||
|
- New action-response bindings select an existing REST action and unambiguous supported component or variable target and always default to `trigger: "onSuccess"`.
|
||||||
|
- Existing legacy `onClick` bindings remain editable and executable and can migrate one-way to `onSuccess`; the UI cannot create new legacy triggers. Deprecated `action.responseMapping` remains unused.
|
||||||
|
- Added visual variable declaration, renaming, type/default-value/description editing, deletion, validation, and reference-aware deletion warnings. Preview continues to copy defaults into ephemeral runtime state.
|
||||||
|
- Updated `valid-visual-rest-actions.json` with representative `onSuccess` component and variable targets.
|
||||||
|
- Focused tests pass under Node 20/npm 10 at 2 suites / 14 tests; the full frontend suite passes at 15 suites / 477 tests. The CRA production compile, backend build, and 13 / 2 / 2 schema matrix pass.
|
||||||
|
- Standalone frontend `tsc --noEmit` still fails in `@types/node/ffi.d.ts` because TypeScript 4.9 cannot parse the resolved `@types/node@26.1.0`; this remains separate from the passing CRA compile.
|
||||||
|
- Docker validation could not begin during the 2026-07-20 audit because Docker Desktop integration is unavailable in this WSL distro. Request-input manual acceptance remains pending and has not been marked accepted. Response-binding/variable manual acceptance is also pending.
|
||||||
|
- Page-load action authoring remains the next Slice 2 increment.
|
||||||
|
|
||||||
|
### Partial Manual Request-Input Checkpoint
|
||||||
|
|
||||||
|
- On 2026-07-20, the user confirmed that adding the `item` query-parameter row and using Request value reference to insert the selected component into `Query: item` looked correct.
|
||||||
|
- This marks the guided query-reference insertion checkpoint complete only. Preview execution, outgoing-request inspection, runtime-state separation, raw-template regression checks, path-destination exclusion, and variable resolution still require explicit confirmation before the overall request-input workflow is accepted.
|
||||||
|
|||||||
@ -27,6 +27,19 @@ Requirements:
|
|||||||
- Add focused tests, update representative fixtures and durable state documents, and run the Node 20/npm 10 focused/full frontend tests, frontend build, backend build, schema matrix, and proportional manual workflow.
|
- Add focused tests, update representative fixtures and durable state documents, and run the Node 20/npm 10 focused/full frontend tests, frontend build, backend build, schema matrix, and proportional manual workflow.
|
||||||
- Report the standalone TypeScript 4.9 / `@types/node` incompatibility separately from the passing CRA production compile.
|
- Report the standalone TypeScript 4.9 / `@types/node` incompatibility separately from the passing CRA production compile.
|
||||||
|
|
||||||
Request-input manual acceptance is still pending. Do not mark it accepted without user confirmation.
|
Before implementing the next increment, complete or coordinate manual acceptance of the most recently completed request-input step:
|
||||||
|
|
||||||
|
1. Start Docker Desktop and run Conductor with Docker Compose; verify both services and the health endpoint.
|
||||||
|
2. Open or create a project containing a Text Input or Dropdown, a Button, and an anonymous REST action.
|
||||||
|
3. Edit the REST action in Actions & Bindings and add a query parameter named `item`.
|
||||||
|
4. In Request value reference, select the input component, select `Query: item`, and click Insert reference.
|
||||||
|
5. Inspect canonical JSON and confirm the query value immediately becomes `{{components.<componentName>.value}}`.
|
||||||
|
6. Confirm no `ComponentEvent.inputMap` data was added and path parameters are not offered as destinations.
|
||||||
|
7. Assign the action to the Button, enter or select a runtime value in Preview, and execute the Button.
|
||||||
|
8. Confirm the outgoing request contains the runtime component value and runtime loading, response, and error state do not leak into canonical JSON.
|
||||||
|
9. Confirm raw URL/header/query/body template editing still works after guided insertion.
|
||||||
|
10. If the project already declares a variable through JSON, repeat insertion with it and verify `{{variables.<name>}}` resolution.
|
||||||
|
|
||||||
|
Record manual acceptance separately from automated coverage in `CODEX.md`, `SLICE2.md`, and `BASELINE.md`. Do not mark it accepted without user confirmation.
|
||||||
|
|
||||||
Do not commit or push unless explicitly requested.
|
Do not commit or push unless explicitly requested.
|
||||||
|
|||||||
45
SLICE2.md
45
SLICE2.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
In progress — component events and request-input authoring complete; response bindings and variables next
|
In progress — response bindings and variable authoring complete; manual acceptance and page-load actions next
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
@ -34,13 +34,13 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
|
|||||||
- [x] Keep Slice 2 authentication authoring anonymous-only and defer credential-backed authentication controls to Slice 3.
|
- [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.
|
- [x] Implement component event configuration for Button `onClick`, including add/change/clear and dangling-reference diagnostics.
|
||||||
- [x] Implement request input authoring through executed REST request templates; do not write inert `ComponentEvent.inputMap`.
|
- [x] Implement request input authoring through executed REST request templates; do not write inert `ComponentEvent.inputMap`.
|
||||||
- [ ] Implement response source and component/variable target selection.
|
- [x] Implement response source and component/variable target selection.
|
||||||
- [ ] Default new action-response bindings to `onSuccess`.
|
- [x] Default new action-response bindings to `onSuccess` without offering legacy `onClick` for new records.
|
||||||
- [ ] Implement page-load action configuration for initial data population.
|
- [ ] Implement page-load action configuration for initial data population.
|
||||||
- [ ] Add variable declaration and default-value editing.
|
- [x] Add variable declaration and typed default-value editing.
|
||||||
- [ ] Warn before deleting referenced actions, variables, or components. Action deletion is covered; variable and component deletion remain.
|
- [ ] Warn before deleting referenced actions, variables, or components. Action and variable deletion are covered; component deletion remains.
|
||||||
- [x] Ensure every REST action visual edit immediately updates canonical JSON while invalid drafts remain local.
|
- [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.
|
- [ ] Add slice-wide tests, examples, and documentation updates. Coverage and the representative fixture are complete through response bindings and variables; page-load and final slice acceptance remain.
|
||||||
|
|
||||||
## Implementation Order
|
## Implementation Order
|
||||||
|
|
||||||
@ -63,9 +63,9 @@ Each increment writes only canonical configuration into the shared project docum
|
|||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- [ ] Standalone frontend TypeScript check passes. TypeScript 4.9 currently cannot parse the resolved `@types/node@26.1.0`; the CRA production compile passes.
|
- [ ] 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] Frontend production build passes through the response-binding and variable increment.
|
||||||
- [x] Editor interaction and Preview dispatch tests pass through the Button `onClick` increment.
|
- [x] Editor interaction and Preview dispatch tests pass through the response-binding and variable increment.
|
||||||
- [x] Increment 2 representative canonical JSON validates against the schema.
|
- [x] The representative canonical JSON validates through the response-binding and variable increment.
|
||||||
- [x] Increment 1 manual REST action authoring and referenced-action deletion workflow passes.
|
- [x] Increment 1 manual REST action authoring and referenced-action deletion workflow passes.
|
||||||
- [ ] Manual workflow-launcher and dependent-data scenarios pass.
|
- [ ] Manual workflow-launcher and dependent-data scenarios pass.
|
||||||
|
|
||||||
@ -92,6 +92,27 @@ Each increment writes only canonical configuration into the shared project docum
|
|||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
|
|
||||||
- Last completed: Increment 2 visual request-input authoring for component and variable values, with automated validation.
|
- Last completed: Visual response-binding and variable authoring, with automated validation.
|
||||||
- Next action: Implement visual response bindings and variable authoring, defaulting new action-response bindings to `onSuccess`.
|
- Next action: Complete manual request-input and response-binding/variable acceptance, then implement visual page-load action authoring.
|
||||||
- Known blockers: Manual request-input acceptance remains pending. Standalone frontend `tsc` retains its recorded tooling limitation.
|
- Known blockers: Docker Desktop integration is unavailable in the current WSL distro, so manual acceptance remains pending. Standalone frontend `tsc` retains its recorded tooling limitation.
|
||||||
|
- Current pushed baseline: `6fd084c Add visual REST request input authoring`.
|
||||||
|
- Continuation and manual-test instructions: `NEXT_SESSION_PROMPT.md`.
|
||||||
|
|
||||||
|
## Response Binding and Variable Increment
|
||||||
|
|
||||||
|
Implemented and audited through 2026-07-20 in the working tree:
|
||||||
|
|
||||||
|
- Visual canonical `project.bindings` create, edit, and delete controls.
|
||||||
|
- Existing REST action response selection with supported component and declared-variable targets.
|
||||||
|
- New bindings default to `onSuccess` and cannot select legacy `onClick`; existing legacy `onClick` bindings are preserved and can migrate one-way to `onSuccess`.
|
||||||
|
- New target choices exclude unsupported component properties and ambiguous duplicate component names.
|
||||||
|
- Visual variable declaration, rename, type, default value, description, and deletion controls with reference-aware warnings.
|
||||||
|
- No `action.responseMapping`, `ComponentEvent.inputMap`, authentication, secrets, page-load execution, or runtime-state persistence was added.
|
||||||
|
- Automated validation passes: 2 focused suites / 14 tests, 15 full frontend suites / 477 tests under Node 20/npm 10, frontend CRA production compile, backend build, and the 13 / 2 / 2 schema matrix.
|
||||||
|
- Standalone TypeScript remains blocked by the documented TypeScript 4.9 / `@types/node@26.1.0` parser incompatibility.
|
||||||
|
|
||||||
|
Manual acceptance is separate from automated coverage. The preceding request-input workflow is still pending user confirmation because Docker Desktop integration is unavailable in this WSL distro. The response-binding and variable workflow is also pending proportional manual acceptance. Neither is marked accepted.
|
||||||
|
|
||||||
|
On 2026-07-20, the user confirmed the partial request-input checkpoint for adding the `item` query parameter and inserting the selected component through `Request value reference` into `Query: item`. The remaining Preview, request inspection, runtime-state, destination-exclusion, raw-template, and variable-resolution checks are still pending; full manual acceptance is not recorded.
|
||||||
|
|
||||||
|
Next increment: page-load action authoring.
|
||||||
|
|||||||
3
TASKS.md
3
TASKS.md
@ -73,7 +73,8 @@ This document summarizes the current implementation and the remaining work requi
|
|||||||
- [x] Implement a visual REST Action editor for creating, editing, duplicating, and deleting anonymous actions without hand-editing JSON.
|
- [x] Implement a visual REST Action editor for creating, editing, duplicating, and deleting anonymous actions without hand-editing JSON.
|
||||||
- [x] Implement visual Button `onClick` configuration with add/change/clear, missing-reference diagnostics, canonical synchronization, and Preview execution.
|
- [x] Implement visual Button `onClick` configuration with add/change/clear, missing-reference diagnostics, canonical synchronization, and Preview execution.
|
||||||
- [x] Implement visual component/variable request-input references through executed REST request templates.
|
- [x] Implement visual component/variable request-input references through executed REST request templates.
|
||||||
- [ ] Implement a visual project-binding editor for request inputs and response targets.
|
- [x] Implement visual canonical response-binding CRUD with REST response sources, supported component/variable targets, `onSuccess` defaults, and legacy `onClick` compatibility.
|
||||||
|
- [x] Implement visual variable declaration and typed default-value editing with reference-aware diagnostics.
|
||||||
- [ ] Implement page-load action configuration for automatically populated components.
|
- [ ] Implement page-load action configuration for automatically populated components.
|
||||||
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
|
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"project": {
|
"project": {
|
||||||
"id": "proj_visual_rest_action_authoring",
|
"id": "proj_visual_rest_action_authoring",
|
||||||
"name": "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.",
|
"description": "Slice 2 fixture covering anonymous visual REST actions, Button onClick assignment, executed request templates, response bindings, and variables.",
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"id": "page_main",
|
"id": "page_main",
|
||||||
@ -38,6 +38,14 @@
|
|||||||
"events": [
|
"events": [
|
||||||
{ "event": "onClick", "actionId": "action_lookup_item" }
|
{ "event": "onClick", "actionId": "action_lookup_item" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmp_result",
|
||||||
|
"type": "JsonViewer",
|
||||||
|
"name": "lookupResult",
|
||||||
|
"position": { "x": 24, "y": 152 },
|
||||||
|
"size": { "width": 420, "height": 220 },
|
||||||
|
"properties": { "label": "Lookup response", "visible": true }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"events": []
|
"events": []
|
||||||
@ -83,8 +91,27 @@
|
|||||||
"authenticationType": "anonymous"
|
"authenticationType": "anonymous"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"bindings": [],
|
"bindings": [
|
||||||
"variables": {},
|
{
|
||||||
|
"id": "binding_response_1",
|
||||||
|
"source": "actions.action_lookup_item.response.body",
|
||||||
|
"target": "components.lookupResult.value",
|
||||||
|
"trigger": "onSuccess"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "binding_response_2",
|
||||||
|
"source": "actions.action_lookup_item.response.body.url",
|
||||||
|
"target": "variables.lastLookupUrl",
|
||||||
|
"trigger": "onSuccess"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variables": {
|
||||||
|
"lastLookupUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"defaultValue": "",
|
||||||
|
"description": "Ephemeral Preview value populated by the lookup response."
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {}
|
"settings": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -530,6 +530,24 @@
|
|||||||
box-shadow: inset 3px 0 0 #2563eb;
|
box-shadow: inset 3px 0 0 #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.configurationEditor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #bfdbfe;
|
||||||
|
background: #f8fbff;
|
||||||
|
box-shadow: inset 3px 0 0 #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variableCard {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.actionEditorHeader {
|
.actionEditorHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { act } from 'react';
|
import React, { act, useEffect } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import type { Root } from 'react-dom/client';
|
import type { Root } from 'react-dom/client';
|
||||||
import { ProjectProvider, useProject } from '../../context/ProjectContext';
|
import { ProjectProvider, useProject } from '../../context/ProjectContext';
|
||||||
@ -17,6 +17,22 @@ function ProjectProbe(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectSeed({ legacyBinding = false }: { legacyBinding?: boolean }): null {
|
||||||
|
const { setDoc } = useProject();
|
||||||
|
useEffect(() => setDoc((doc) => ({
|
||||||
|
...doc,
|
||||||
|
project: {
|
||||||
|
...doc.project,
|
||||||
|
pages: [{ ...doc.project.pages[0], components: [
|
||||||
|
{ id: 'cmp_result', type: 'JsonViewer', name: 'result', position: { x: 0, y: 0 }, size: { width: 200, height: 100 }, properties: {} },
|
||||||
|
] }],
|
||||||
|
actions: [{ id: 'action_lookup', name: 'Lookup', method: 'GET', url: 'https://example.com', headers: {}, queryParameters: {}, pathParameters: {}, bodyTemplate: '', authenticationType: 'anonymous' }],
|
||||||
|
bindings: legacyBinding ? [{ id: 'binding_legacy', source: 'actions.action_lookup.response.body', target: 'components.result.value', trigger: 'onClick' }] : [],
|
||||||
|
},
|
||||||
|
})), [setDoc]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function currentDocument(container: HTMLElement): ProjectDocument {
|
function currentDocument(container: HTMLElement): ProjectDocument {
|
||||||
const probe = container.querySelector('[data-testid="project-probe"]');
|
const probe = container.querySelector('[data-testid="project-probe"]');
|
||||||
if (!probe?.textContent) throw new Error('Project probe was not rendered.');
|
if (!probe?.textContent) throw new Error('Project probe was not rendered.');
|
||||||
@ -234,4 +250,69 @@ describe('Actions & Bindings visual REST action authoring', () => {
|
|||||||
'Delete REST action "New REST Action"? This cannot be undone.',
|
'Delete REST action "New REST Action"? This cannot be undone.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('authors variables and onSuccess response bindings in canonical state', () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
||||||
|
});
|
||||||
|
|
||||||
|
click(container.querySelector('[data-testid="add-variable"]'));
|
||||||
|
setControlValue(container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]'), 'capturedId');
|
||||||
|
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Variable type"]'), 'number');
|
||||||
|
setControlValue(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Variable default value"]'), '7');
|
||||||
|
click(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Save variable') ?? null);
|
||||||
|
expect(currentDocument(container).project.variables.capturedId).toEqual({ type: 'number', defaultValue: 7 });
|
||||||
|
|
||||||
|
click(container.querySelector('[data-testid="add-response-binding"]'));
|
||||||
|
let binding = currentDocument(container).project.bindings[0];
|
||||||
|
expect(binding).toMatchObject({ source: 'actions.action_lookup.response.body', target: 'components.result.value', trigger: 'onSuccess' });
|
||||||
|
setControlValue(container.querySelector<HTMLInputElement>('input[aria-label="Binding response path"]'), 'body.id');
|
||||||
|
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Binding target"]'), 'variables.capturedId');
|
||||||
|
binding = currentDocument(container).project.bindings[0];
|
||||||
|
expect(binding.source).toBe('actions.action_lookup.response.body.id');
|
||||||
|
expect(binding.target).toBe('variables.capturedId');
|
||||||
|
expect(binding.trigger).toBe('onSuccess');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns about references when deleting a variable', () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
||||||
|
});
|
||||||
|
click(container.querySelector('[data-testid="add-variable"]'));
|
||||||
|
setControlValue(container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]'), 'capturedId');
|
||||||
|
click(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Save variable') ?? null);
|
||||||
|
click(container.querySelector('[data-testid="add-response-binding"]'));
|
||||||
|
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Binding target"]'), 'variables.capturedId');
|
||||||
|
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
click(container.querySelector('button[aria-label="Delete variable capturedId"]'));
|
||||||
|
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('binding "binding_response_1"'));
|
||||||
|
expect(currentDocument(container).project.variables.capturedId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not offer legacy onClick for new bindings and resets after deletion', () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
||||||
|
});
|
||||||
|
click(container.querySelector('[data-testid="add-response-binding"]'));
|
||||||
|
const trigger = container.querySelector<HTMLSelectElement>('select[aria-label="Binding trigger"]');
|
||||||
|
expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess']);
|
||||||
|
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
click(container.querySelector('button[aria-label="Delete binding binding_response_1"]'));
|
||||||
|
expect(currentDocument(container).project.bindings).toEqual([]);
|
||||||
|
expect(container.querySelector<HTMLButtonElement>('[data-testid="add-response-binding"]')?.disabled).toBe(false);
|
||||||
|
expect(confirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves an existing legacy onClick binding and allows one-way migration', () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(<ProjectProvider><ProjectSeed legacyBinding /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
||||||
|
});
|
||||||
|
click(container.querySelector('button[aria-label="Edit binding binding_legacy"]'));
|
||||||
|
const trigger = container.querySelector<HTMLSelectElement>('select[aria-label="Binding trigger"]');
|
||||||
|
expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess', 'onClick']);
|
||||||
|
expect(currentDocument(container).project.bindings[0].trigger).toBe('onClick');
|
||||||
|
setControlValue(trigger, 'onSuccess');
|
||||||
|
expect(currentDocument(container).project.bindings[0].trigger).toBe('onSuccess');
|
||||||
|
expect(Array.from(container.querySelectorAll<HTMLSelectElement>('select[aria-label="Binding trigger"] option')).map((option) => option.value)).toEqual(['onSuccess']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -50,6 +50,17 @@ import {
|
|||||||
} from '../Preview/bindingUtils';
|
} from '../Preview/bindingUtils';
|
||||||
import styles from './ActionInspector.module.css';
|
import styles from './ActionInspector.module.css';
|
||||||
import RestActionEditor from './RestActionEditor';
|
import RestActionEditor from './RestActionEditor';
|
||||||
|
import ResponseBindingEditor from './ResponseBindingEditor';
|
||||||
|
import VariableEditor from './VariableEditor';
|
||||||
|
import {
|
||||||
|
createResponseBinding,
|
||||||
|
findVariableReferences,
|
||||||
|
removeBindingAt,
|
||||||
|
removeVariable,
|
||||||
|
replaceBindingAt,
|
||||||
|
responseTargetOptions,
|
||||||
|
setVariable,
|
||||||
|
} from './configurationUtils';
|
||||||
import {
|
import {
|
||||||
appendRestAction,
|
appendRestAction,
|
||||||
buildDeleteConfirmation,
|
buildDeleteConfirmation,
|
||||||
@ -537,8 +548,7 @@ function methodClass(method: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseActionSource(expr: string): string | null {
|
function parseActionSource(expr: string): string | null {
|
||||||
const m = /^actions\.([^.]+)\.response$/.exec(expr);
|
return parseActionSourcePath(expr)?.actionId ?? null;
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseComponentExpr(expr: string): string | null {
|
function parseComponentExpr(expr: string): string | null {
|
||||||
@ -787,6 +797,8 @@ type BindingCardProps = {
|
|||||||
allComponents: CanvasComponent[];
|
allComponents: CanvasComponent[];
|
||||||
allActions: RestAction[];
|
allActions: RestAction[];
|
||||||
diags: Diagnostic[];
|
diags: Diagnostic[];
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BindingCard({
|
function BindingCard({
|
||||||
@ -794,6 +806,8 @@ function BindingCard({
|
|||||||
allComponents,
|
allComponents,
|
||||||
allActions,
|
allActions,
|
||||||
diags,
|
diags,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
}: BindingCardProps): React.ReactElement {
|
}: BindingCardProps): React.ReactElement {
|
||||||
// Resolve source
|
// Resolve source
|
||||||
const sourceActionId = parseActionSource(binding.source);
|
const sourceActionId = parseActionSource(binding.source);
|
||||||
@ -865,6 +879,10 @@ function BindingCard({
|
|||||||
|
|
||||||
{/* ── Diagnostics ── */}
|
{/* ── Diagnostics ── */}
|
||||||
<DiagList diags={diags} />
|
<DiagList diags={diags} />
|
||||||
|
<div className={styles.actionControls}>
|
||||||
|
<button type="button" className={styles.smallButton} onClick={onEdit} aria-label={`Edit binding ${binding.id}`}>Edit</button>
|
||||||
|
<button type="button" className={styles.dangerButton} onClick={onDelete} aria-label={`Delete binding ${binding.id}`}>Delete</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -881,6 +899,9 @@ function ActionInspector(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [editingActionIndex, setEditingActionIndex] = useState<number | null>(null);
|
const [editingActionIndex, setEditingActionIndex] = useState<number | null>(null);
|
||||||
|
const [editingBindingIndex, setEditingBindingIndex] = useState<number | null>(null);
|
||||||
|
const [editingVariableName, setEditingVariableName] = useState<string | null>(null);
|
||||||
|
const [addingVariable, setAddingVariable] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -891,6 +912,12 @@ function ActionInspector(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [actions.length, editingActionIndex]);
|
}, [actions.length, editingActionIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingBindingIndex !== null && editingBindingIndex >= bindings.length) {
|
||||||
|
setEditingBindingIndex(null);
|
||||||
|
}
|
||||||
|
}, [bindings.length, editingBindingIndex]);
|
||||||
|
|
||||||
const handleAddAction = useCallback(() => {
|
const handleAddAction = useCallback(() => {
|
||||||
const action = createRestAction(
|
const action = createRestAction(
|
||||||
actions,
|
actions,
|
||||||
@ -934,6 +961,27 @@ function ActionInspector(): React.ReactElement {
|
|||||||
[doc, setDoc],
|
[doc, setDoc],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const targetOptions = useMemo(
|
||||||
|
() => responseTargetOptions(allComponents, variables),
|
||||||
|
[allComponents, variables],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddBinding = useCallback(() => {
|
||||||
|
if (actions.length === 0 || targetOptions.length === 0) return;
|
||||||
|
const binding = createResponseBinding(bindings, actions[0], targetOptions[0].value);
|
||||||
|
setDoc((current) => ({ ...current, project: { ...current.project, bindings: [...current.project.bindings, binding] } }));
|
||||||
|
setEditingBindingIndex(bindings.length);
|
||||||
|
}, [actions, bindings, setDoc, targetOptions]);
|
||||||
|
|
||||||
|
const handleDeleteVariable = useCallback((name: string) => {
|
||||||
|
const references = findVariableReferences(doc, name);
|
||||||
|
const suffix = references.length > 0
|
||||||
|
? `\n\nReferences will become unresolved:\n- ${references.join('\n- ')}`
|
||||||
|
: '';
|
||||||
|
if (!window.confirm(`Delete variable "${name}"?${suffix}`)) return;
|
||||||
|
setDoc((current) => removeVariable(current, name));
|
||||||
|
}, [doc, setDoc]);
|
||||||
|
|
||||||
// Compute all diagnostics once per render cycle
|
// Compute all diagnostics once per render cycle
|
||||||
const diagMap = useMemo(
|
const diagMap = useMemo(
|
||||||
() => computeDiagnostics(actions, bindings, allComponents, variables),
|
() => computeDiagnostics(actions, bindings, allComponents, variables),
|
||||||
@ -1048,7 +1096,10 @@ function ActionInspector(): React.ReactElement {
|
|||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<span className={styles.sectionTitle}>Bindings</span>
|
<span className={styles.sectionTitle}>Bindings</span>
|
||||||
|
<div className={styles.sectionHeaderMeta}>
|
||||||
<span className={styles.sectionCount}>{bindings.length}</span>
|
<span className={styles.sectionCount}>{bindings.length}</span>
|
||||||
|
<button type="button" className={styles.primaryButton} data-testid="add-response-binding" onClick={handleAddBinding} disabled={actions.length === 0 || targetOptions.length === 0 || editingBindingIndex !== null} title={actions.length === 0 ? 'Add a REST action first.' : targetOptions.length === 0 ? 'Add a supported target component or variable first.' : undefined}>+ Add response binding</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary banner */}
|
{/* Summary banner */}
|
||||||
@ -1065,22 +1116,46 @@ function ActionInspector(): React.ReactElement {
|
|||||||
|
|
||||||
{bindings.length === 0 ? (
|
{bindings.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
No bindings defined. Add bindings to{' '}
|
No response bindings defined. Add a REST action and a supported
|
||||||
<code>project.bindings</code> in the JSON Editor.
|
component or variable target to configure one visually.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
bindings.map((binding) => (
|
bindings.map((binding, bindingIndex) => (
|
||||||
<BindingCard
|
<React.Fragment key={`${binding.id}-${bindingIndex}`}>
|
||||||
key={binding.id}
|
<BindingCard binding={binding} allComponents={allComponents} allActions={actions} diags={diagMap[binding.id] ?? []} onEdit={() => setEditingBindingIndex(bindingIndex)} onDelete={() => {
|
||||||
binding={binding}
|
if (window.confirm(`Delete binding "${binding.id}"?`)) {
|
||||||
allComponents={allComponents}
|
setDoc((current) => removeBindingAt(current, bindingIndex));
|
||||||
allActions={actions}
|
setEditingBindingIndex((current) => {
|
||||||
diags={diagMap[binding.id] ?? []}
|
if (current === null || current === bindingIndex) return null;
|
||||||
/>
|
return current > bindingIndex ? current - 1 : current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
{editingBindingIndex === bindingIndex && <ResponseBindingEditor binding={binding} actions={actions} components={allComponents} variables={variables} onChange={(updated) => setDoc((current) => replaceBindingAt(current, bindingIndex, updated))} onDone={() => setEditingBindingIndex(null)} />}
|
||||||
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.sectionTitle}>Variables</span>
|
||||||
|
<div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{Object.keys(variables).length}</span><button type="button" className={styles.primaryButton} data-testid="add-variable" disabled={addingVariable || editingVariableName !== null} onClick={() => setAddingVariable(true)}>+ Add variable</button></div>
|
||||||
|
</div>
|
||||||
|
{addingVariable && <VariableEditor name="" variable={{ type: 'string' }} variables={variables} isNew onSave={(name, variable) => { setDoc((current) => setVariable(current, name, variable)); setAddingVariable(false); }} onCancel={() => setAddingVariable(false)} />}
|
||||||
|
{Object.keys(variables).length === 0 && !addingVariable ? <div className={styles.empty}>No variables declared.</div> : Object.entries(variables).map(([name, variable]) => (
|
||||||
|
<React.Fragment key={name}>
|
||||||
|
<div className={styles.variableCard}>
|
||||||
|
<div><span className={styles.bindingId}>{name}</span> <span className={styles.triggerBadge}>{variable.type}</span></div>
|
||||||
|
<code className={styles.bindingExpr}>{variable.defaultValue === undefined ? 'No default value' : JSON.stringify(variable.defaultValue)}</code>
|
||||||
|
{variable.description && <span className={styles.actionDescription}>{variable.description}</span>}
|
||||||
|
<div className={styles.actionControls}><button type="button" className={styles.smallButton} aria-label={`Edit variable ${name}`} onClick={() => setEditingVariableName(name)}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div>
|
||||||
|
</div>
|
||||||
|
{editingVariableName === name && <VariableEditor name={name} variable={variable} variables={variables} onSave={(nextName, nextVariable) => { setDoc((current) => setVariable(current, nextName, nextVariable, name)); setEditingVariableName(null); }} onCancel={() => setEditingVariableName(null)} />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ══ Overall summary (only shown when there are issues) ═══════ */}
|
{/* ══ Overall summary (only shown when there are issues) ═══════ */}
|
||||||
{totalIssues === 0 && (actions.length > 0 || bindings.length > 0) && (
|
{totalIssues === 0 && (actions.length > 0 || bindings.length > 0) && (
|
||||||
<div style={{ fontSize: 12, color: '#15803d' }}>
|
<div style={{ fontSize: 12, color: '#15803d' }}>
|
||||||
|
|||||||
@ -0,0 +1,62 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import type { Binding, CanvasComponent, RestAction, Variable } from '../../types/project';
|
||||||
|
import { responseTargetOptions } from './configurationUtils';
|
||||||
|
import styles from './ActionInspector.module.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
binding: Binding;
|
||||||
|
actions: RestAction[];
|
||||||
|
components: CanvasComponent[];
|
||||||
|
variables: Record<string, Variable>;
|
||||||
|
onChange: (binding: Binding) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResponseBindingEditor({ binding, actions, components, variables, onChange, onDone }: Props): React.ReactElement {
|
||||||
|
const targets = useMemo(() => responseTargetOptions(components, variables), [components, variables]);
|
||||||
|
const isLegacyOnClick = binding.trigger === 'onClick';
|
||||||
|
const sourceMatch = /^actions\.([^.]+)\.response(?:\.(.*))?$/.exec(binding.source);
|
||||||
|
const actionId = sourceMatch?.[1] ?? '';
|
||||||
|
const responsePath = sourceMatch?.[2] ?? '';
|
||||||
|
const updateSource = (nextActionId: string, nextPath: string) => onChange({
|
||||||
|
...binding,
|
||||||
|
source: `actions.${nextActionId}.response${nextPath.trim() ? `.${nextPath.trim().replace(/^\.+/, '')}` : ''}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.configurationEditor} data-testid={`binding-editor-${binding.id}`}>
|
||||||
|
<label className={styles.formField}>
|
||||||
|
<span className={styles.formLabel}>REST action response</span>
|
||||||
|
<select className={styles.formInput} aria-label="Binding response action" value={actionId} onChange={(event) => updateSource(event.target.value, responsePath)}>
|
||||||
|
{actions.map((action) => <option key={action.id} value={action.id}>{action.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.formField}>
|
||||||
|
<span className={styles.formLabel}>Response path</span>
|
||||||
|
<input className={styles.formInput} aria-label="Binding response path" value={responsePath} placeholder="body or body.items" onChange={(event) => updateSource(actionId, event.target.value)} />
|
||||||
|
<span className={styles.formHint}>Leave blank for the full response envelope.</span>
|
||||||
|
</label>
|
||||||
|
<label className={styles.formField}>
|
||||||
|
<span className={styles.formLabel}>Target</span>
|
||||||
|
<select className={styles.formInput} aria-label="Binding target" value={binding.target} onChange={(event) => onChange({ ...binding, target: event.target.value })}>
|
||||||
|
{!targets.some((target) => target.value === binding.target) && <option value={binding.target}>{binding.target} (unsupported or missing)</option>}
|
||||||
|
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.formField}>
|
||||||
|
<span className={styles.formLabel}>Trigger</span>
|
||||||
|
<select className={styles.formInput} aria-label="Binding trigger" value={binding.trigger ?? 'onChange'} onChange={(event) => onChange({ ...binding, trigger: event.target.value })}>
|
||||||
|
<option value="onSuccess">On success</option>
|
||||||
|
{isLegacyOnClick && <option value="onClick">On click (legacy compatibility)</option>}
|
||||||
|
{(binding.trigger !== 'onSuccess' && !isLegacyOnClick) && <option value={binding.trigger ?? 'onChange'}>{binding.trigger ?? 'onChange'} (existing)</option>}
|
||||||
|
</select>
|
||||||
|
{isLegacyOnClick && <span className={styles.formHint}>This existing legacy trigger remains compatible. Choose On success to migrate it; new bindings cannot select On click.</span>}
|
||||||
|
</label>
|
||||||
|
<label className={styles.formField}>
|
||||||
|
<span className={styles.formLabel}>Transform (optional)</span>
|
||||||
|
<input className={styles.formInput} aria-label="Binding transform" value={binding.transform ?? ''} onChange={(event) => onChange({ ...binding, transform: event.target.value || undefined })} />
|
||||||
|
</label>
|
||||||
|
<button type="button" className={styles.primaryButton} onClick={onDone}>Done</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/ActionInspector/VariableEditor.tsx
Normal file
41
frontend/src/components/ActionInspector/VariableEditor.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { Variable, VariableType } from '../../types/project';
|
||||||
|
import { formatVariableDefault, parseVariableDefault, validateVariableName } from './configurationUtils';
|
||||||
|
import styles from './ActionInspector.module.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
variable: Variable;
|
||||||
|
variables: Record<string, Variable>;
|
||||||
|
isNew?: boolean;
|
||||||
|
onSave: (name: string, variable: Variable) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPES: VariableType[] = ['string', 'number', 'boolean', 'object', 'array'];
|
||||||
|
|
||||||
|
export default function VariableEditor({ name, variable, variables, isNew = false, onSave, onCancel }: Props): React.ReactElement {
|
||||||
|
const [nameDraft, setNameDraft] = useState(name);
|
||||||
|
const [type, setType] = useState(variable.type);
|
||||||
|
const [defaultDraft, setDefaultDraft] = useState(() => formatVariableDefault(variable));
|
||||||
|
const [description, setDescription] = useState(variable.description ?? '');
|
||||||
|
const nameError = validateVariableName(nameDraft, variables, isNew ? undefined : name);
|
||||||
|
let defaultValue: unknown;
|
||||||
|
let defaultError: string | null = null;
|
||||||
|
try { defaultValue = parseVariableDefault(type, defaultDraft); } catch (error) { defaultError = error instanceof Error ? error.message : String(error); }
|
||||||
|
const valid = !nameError && !defaultError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === 'boolean' && defaultDraft !== '' && defaultDraft !== 'true' && defaultDraft !== 'false') setDefaultDraft('false');
|
||||||
|
}, [defaultDraft, type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.configurationEditor} data-testid={`variable-editor-${name || 'new'}`}>
|
||||||
|
<label className={styles.formField}><span className={styles.formLabel}>Variable name</span><input className={styles.formInput} aria-label="Variable name" value={nameDraft} onChange={(event) => setNameDraft(event.target.value)} />{nameError && <span className={styles.inlineError} role="alert">{nameError}</span>}</label>
|
||||||
|
<label className={styles.formField}><span className={styles.formLabel}>Type</span><select className={styles.formInput} aria-label="Variable type" value={type} onChange={(event) => setType(event.target.value as VariableType)}>{TYPES.map((candidate) => <option key={candidate}>{candidate}</option>)}</select></label>
|
||||||
|
<label className={styles.formField}><span className={styles.formLabel}>Default value</span><textarea className={styles.formInput} aria-label="Variable default value" value={defaultDraft} onChange={(event) => setDefaultDraft(event.target.value)} placeholder="Optional design-time default" />{defaultError && <span className={styles.inlineError} role="alert">{defaultError}</span>}<span className={styles.formHint}>Preview copies this value into ephemeral runtime state.</span></label>
|
||||||
|
<label className={styles.formField}><span className={styles.formLabel}>Description</span><input className={styles.formInput} aria-label="Variable description" value={description} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||||
|
<div className={styles.actionControls}><button type="button" className={styles.primaryButton} disabled={!valid} onClick={() => onSave(nameDraft, { type, ...(defaultValue !== undefined ? { defaultValue } : {}), ...(description ? { description } : {}) })}>Save variable</button><button type="button" className={styles.smallButton} onClick={onCancel}>Cancel</button></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import type { CanvasComponent, ProjectDocument, RestAction } from '../../types/project';
|
||||||
|
import {
|
||||||
|
createResponseBinding,
|
||||||
|
findVariableReferences,
|
||||||
|
parseVariableDefault,
|
||||||
|
removeBindingAt,
|
||||||
|
removeVariable,
|
||||||
|
responseTargetOptions,
|
||||||
|
setVariable,
|
||||||
|
validateVariableName,
|
||||||
|
} from './configurationUtils';
|
||||||
|
|
||||||
|
const action: RestAction = {
|
||||||
|
id: 'action_lookup', name: 'Lookup', method: 'GET', url: 'https://example.com',
|
||||||
|
headers: {}, queryParameters: {}, pathParameters: {}, bodyTemplate: '', authenticationType: 'anonymous',
|
||||||
|
};
|
||||||
|
|
||||||
|
const components: CanvasComponent[] = [
|
||||||
|
{ id: 'label', type: 'Label', name: 'status', position: { x: 0, y: 0 }, size: { width: 10, height: 10 }, properties: {} },
|
||||||
|
{ id: 'dropdown', type: 'Dropdown', name: 'choices', position: { x: 0, y: 0 }, size: { width: 10, height: 10 }, properties: {} },
|
||||||
|
{ id: 'input', type: 'TextInput', name: 'query', position: { x: 0, y: 0 }, size: { width: 10, height: 10 }, properties: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
const doc: ProjectDocument = {
|
||||||
|
schemaVersion: '0.1.0',
|
||||||
|
project: { id: 'p', name: 'P', pages: [], actions: [action], bindings: [], variables: {}, settings: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('creates canonical action-response bindings with onSuccess and a unique ID', () => {
|
||||||
|
const first = createResponseBinding([], action, 'components.status.value');
|
||||||
|
const second = createResponseBinding([first], action, 'variables.result');
|
||||||
|
expect(first).toEqual({ id: 'binding_response_1', source: 'actions.action_lookup.response.body', target: 'components.status.value', trigger: 'onSuccess' });
|
||||||
|
expect(second.id).toBe('binding_response_2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers only runtime-supported component targets plus variables', () => {
|
||||||
|
expect(responseTargetOptions(components, { result: { type: 'object' } })).toEqual([
|
||||||
|
{ value: 'components.status.value', label: 'status (Label.value)' },
|
||||||
|
{ value: 'components.choices.options', label: 'choices (Dropdown.options)' },
|
||||||
|
{ value: 'variables.result', label: 'result (variable)' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not offer ambiguous duplicate component names as new targets', () => {
|
||||||
|
expect(responseTargetOptions([
|
||||||
|
components[0],
|
||||||
|
{ ...components[0], id: 'other-label' },
|
||||||
|
], {})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses typed defaults and reports invalid declarations', () => {
|
||||||
|
expect(parseVariableDefault('string', '5')).toBe('5');
|
||||||
|
expect(parseVariableDefault('number', '5')).toBe(5);
|
||||||
|
expect(parseVariableDefault('boolean', 'false')).toBe(false);
|
||||||
|
expect(parseVariableDefault('array', '[1,2]')).toEqual([1, 2]);
|
||||||
|
expect(() => parseVariableDefault('object', '[]')).toThrow('JSON object');
|
||||||
|
expect(validateVariableName('bad.name', {})).not.toBeNull();
|
||||||
|
expect(validateVariableName('result', { result: { type: 'string' } })).toContain('already exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes and removes variables and bindings without touching other canonical state', () => {
|
||||||
|
const withVariable = setVariable(doc, 'result', { type: 'string', defaultValue: 'ready' });
|
||||||
|
const binding = createResponseBinding([], action, 'variables.result');
|
||||||
|
const withBinding = { ...withVariable, project: { ...withVariable.project, bindings: [binding] } };
|
||||||
|
expect(removeBindingAt(withBinding, 0).project.bindings).toEqual([]);
|
||||||
|
expect(removeVariable(withVariable, 'result').project.variables).toEqual({});
|
||||||
|
expect(withVariable.project.actions).toBe(doc.project.actions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds binding and request-template references before variable deletion', () => {
|
||||||
|
const referenced: ProjectDocument = {
|
||||||
|
...doc,
|
||||||
|
project: {
|
||||||
|
...doc.project,
|
||||||
|
actions: [{ ...action, queryParameters: { item: '{{variables.result}}' }, pathParameters: { legacy: '{{variables.result}}' } }],
|
||||||
|
bindings: [{ id: 'b', source: 'actions.action_lookup.response', target: 'variables.result', trigger: 'onSuccess' }],
|
||||||
|
variables: { result: { type: 'string' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(findVariableReferences(referenced, 'result')).toEqual(['binding "b"', 'action "Lookup"']);
|
||||||
|
});
|
||||||
163
frontend/src/components/ActionInspector/configurationUtils.ts
Normal file
163
frontend/src/components/ActionInspector/configurationUtils.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import type {
|
||||||
|
Binding,
|
||||||
|
CanvasComponent,
|
||||||
|
ProjectDocument,
|
||||||
|
RestAction,
|
||||||
|
Variable,
|
||||||
|
VariableType,
|
||||||
|
} from '../../types/project';
|
||||||
|
import { isTargetPropertySupported } from '../Preview/bindingUtils';
|
||||||
|
|
||||||
|
export type ResponseTargetOption = { value: string; label: string };
|
||||||
|
|
||||||
|
function nextId(prefix: string, used: Set<string>): string {
|
||||||
|
let index = 1;
|
||||||
|
while (used.has(`${prefix}_${index}`)) index += 1;
|
||||||
|
return `${prefix}_${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function responseTargetOptions(
|
||||||
|
components: CanvasComponent[],
|
||||||
|
variables: Record<string, Variable>,
|
||||||
|
): ResponseTargetOption[] {
|
||||||
|
const options: ResponseTargetOption[] = [];
|
||||||
|
const nameCounts = new Map<string, number>();
|
||||||
|
for (const component of components) {
|
||||||
|
nameCounts.set(component.name, (nameCounts.get(component.name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const component of components) {
|
||||||
|
// Runtime resolution requires an unambiguous component name. Diagnostics
|
||||||
|
// still report existing bindings that point at duplicate names, but the
|
||||||
|
// visual editor must not create a new ambiguous target.
|
||||||
|
if (nameCounts.get(component.name) !== 1) continue;
|
||||||
|
for (const property of ['value', 'options', 'rows']) {
|
||||||
|
if (isTargetPropertySupported(component.type, property)) {
|
||||||
|
options.push({
|
||||||
|
value: `components.${component.name}.${property}`,
|
||||||
|
label: `${component.name} (${component.type}.${property})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const name of Object.keys(variables)) {
|
||||||
|
options.push({ value: `variables.${name}`, label: `${name} (variable)` });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResponseBinding(
|
||||||
|
bindings: Binding[],
|
||||||
|
action: RestAction,
|
||||||
|
target: string,
|
||||||
|
): Binding {
|
||||||
|
return {
|
||||||
|
id: nextId('binding_response', new Set(bindings.map((binding) => binding.id))),
|
||||||
|
source: `actions.${action.id}.response.body`,
|
||||||
|
target,
|
||||||
|
trigger: 'onSuccess',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceBindingAt(
|
||||||
|
doc: ProjectDocument,
|
||||||
|
index: number,
|
||||||
|
binding: Binding,
|
||||||
|
): ProjectDocument {
|
||||||
|
const bindings = [...doc.project.bindings];
|
||||||
|
bindings[index] = binding;
|
||||||
|
return { ...doc, project: { ...doc.project, bindings } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBindingAt(doc: ProjectDocument, index: number): ProjectDocument {
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
project: {
|
||||||
|
...doc.project,
|
||||||
|
bindings: doc.project.bindings.filter((_binding, candidate) => candidate !== index),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateVariableName(
|
||||||
|
name: string,
|
||||||
|
variables: Record<string, Variable>,
|
||||||
|
originalName?: string,
|
||||||
|
): string | null {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) {
|
||||||
|
return 'Use a letter or underscore first, followed by letters, numbers, underscores, or hyphens.';
|
||||||
|
}
|
||||||
|
if (name !== originalName && Object.prototype.hasOwnProperty.call(variables, name)) {
|
||||||
|
return `Variable "${name}" already exists.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVariableDefault(type: VariableType, raw: string): unknown {
|
||||||
|
if (type === 'string') return raw;
|
||||||
|
if (raw.trim() === '') return undefined;
|
||||||
|
if (type === 'number') {
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value)) throw new Error('Enter a finite number.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (type === 'boolean') {
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
throw new Error('Enter true or false.');
|
||||||
|
}
|
||||||
|
let value: unknown;
|
||||||
|
try { value = JSON.parse(raw); } catch (_error) { throw new Error('Enter valid JSON.'); }
|
||||||
|
if (type === 'array' && !Array.isArray(value)) throw new Error('Enter a JSON array.');
|
||||||
|
if (type === 'object' && (value === null || Array.isArray(value) || typeof value !== 'object')) {
|
||||||
|
throw new Error('Enter a JSON object.');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVariableDefault(variable: Variable): string {
|
||||||
|
if (variable.defaultValue === undefined) return '';
|
||||||
|
if (variable.type === 'string') return String(variable.defaultValue);
|
||||||
|
if (variable.type === 'object' || variable.type === 'array') {
|
||||||
|
return JSON.stringify(variable.defaultValue, null, 2);
|
||||||
|
}
|
||||||
|
return String(variable.defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVariable(
|
||||||
|
doc: ProjectDocument,
|
||||||
|
name: string,
|
||||||
|
variable: Variable,
|
||||||
|
originalName?: string,
|
||||||
|
): ProjectDocument {
|
||||||
|
const variables = { ...doc.project.variables };
|
||||||
|
if (originalName && originalName !== name) delete variables[originalName];
|
||||||
|
variables[name] = variable;
|
||||||
|
return { ...doc, project: { ...doc.project, variables } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeVariable(doc: ProjectDocument, name: string): ProjectDocument {
|
||||||
|
const variables = { ...doc.project.variables };
|
||||||
|
delete variables[name];
|
||||||
|
return { ...doc, project: { ...doc.project, variables } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findVariableReferences(doc: ProjectDocument, name: string): string[] {
|
||||||
|
const needle = `variables.${name}`;
|
||||||
|
const references: string[] = [];
|
||||||
|
doc.project.bindings.forEach((binding) => {
|
||||||
|
if (binding.source === needle || binding.target === needle) references.push(`binding "${binding.id}"`);
|
||||||
|
});
|
||||||
|
doc.project.actions.forEach((action) => {
|
||||||
|
const values = [
|
||||||
|
action.url,
|
||||||
|
action.bodyTemplate,
|
||||||
|
...Object.values(action.headers ?? {}),
|
||||||
|
...Object.values(action.queryParameters ?? {}),
|
||||||
|
// Variable path-parameter templates are compatibility-only and produce a
|
||||||
|
// diagnostic, but they are still canonical references worth warning about.
|
||||||
|
...Object.values(action.pathParameters ?? {}),
|
||||||
|
];
|
||||||
|
if (values.some((value) => value?.includes(`{{${needle}}}`))) references.push(`action "${action.name}"`);
|
||||||
|
});
|
||||||
|
return references;
|
||||||
|
}
|
||||||
@ -49,10 +49,10 @@ export interface Variable {
|
|||||||
* "actions.<actionId>.response" — action response (used in Step 15)
|
* "actions.<actionId>.response" — action response (used in Step 15)
|
||||||
* "variables.<variableName>" — global variable
|
* "variables.<variableName>" — global variable
|
||||||
*
|
*
|
||||||
* For Step 15 button-click bindings:
|
* For canonical action-response bindings:
|
||||||
* source: "actions.<actionId>.response"
|
* source: "actions.<actionId>.response"
|
||||||
* target: "components.<viewerName>.value"
|
* target: "components.<viewerName>.value"
|
||||||
* trigger: "onClick"
|
* trigger: "onSuccess"
|
||||||
*
|
*
|
||||||
* The button component that fires the action is declared in the component's own
|
* The button component that fires the action is declared in the component's own
|
||||||
* events[] array (ComponentEvent), not in this binding.
|
* events[] array (ComponentEvent), not in this binding.
|
||||||
@ -71,7 +71,8 @@ export type Binding = {
|
|||||||
*/
|
*/
|
||||||
target: string;
|
target: string;
|
||||||
/**
|
/**
|
||||||
* Event name that activates this binding (e.g. "onClick", "onChange", "onLoad").
|
* Event name that activates this binding. New action-response bindings use
|
||||||
|
* "onSuccess"; "onClick" remains compatible for legacy component targets.
|
||||||
* Defaults to "onChange" if omitted.
|
* Defaults to "onChange" if omitted.
|
||||||
*/
|
*/
|
||||||
trigger?: string;
|
trigger?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user