Add visual response bindings and variables

This commit is contained in:
Victor Wiebe 2026-07-20 14:04:12 -04:00
parent 6fd084c8b3
commit 9e4eb33e05
14 changed files with 723 additions and 96 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

113
TASKS.md
View File

@ -68,74 +68,75 @@ This document summarizes the current implementation and the remaining work requi
- [x] Implement the Container/Card component in the palette, canvas, property editor, Preview runtime, and tests. - [x] Implement the Container/Card component in the palette, canvas, property editor, Preview runtime, and tests.
- [x] Remove the schema/runtime component-type mismatch; all eleven schema-supported types now render in the editor and Preview. - [x] Remove the schema/runtime component-type mismatch; all eleven schema-supported types now render in the editor and Preview.
### Visual configuration workflows ### Visual configuration workflows
- [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.
- [ ] Implement page-load action configuration for automatically populated components. - [x] Implement visual variable declaration and typed default-value editing with reference-aware diagnostics.
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification. - [ ] Implement page-load action configuration for automatically populated components.
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
### Authentication and secrets ### Authentication and secrets
- [ ] Implement Basic authentication during proxy execution. - [ ] Implement Basic authentication during proxy execution.
- [ ] Implement Bearer token authentication during proxy execution. - [ ] Implement Bearer token authentication during proxy execution.
- [ ] Implement API-key header authentication during proxy execution. - [ ] Implement API-key header authentication during proxy execution.
- [ ] Implement API-key query-parameter authentication during proxy execution. - [ ] Implement API-key query-parameter authentication during proxy execution.
- [ ] Add secure server-side credential storage and secret references. - [ ] Add secure server-side credential storage and secret references.
- [ ] Ensure exported project JSON excludes secret values. - [ ] Ensure exported project JSON excludes secret values.
- [ ] Mask credentials and sensitive values in logs and error messages. - [ ] Mask credentials and sensitive values in logs and error messages.
### Proxy security and observability ### Proxy security and observability
- [ ] Add permitted-origin or endpoint allowlisting for proxied requests. - [ ] Add permitted-origin or endpoint allowlisting for proxied requests.
- [ ] Add SSRF protections and stricter URL validation. - [ ] Add SSRF protections and stricter URL validation.
- [ ] Validate and sanitize forwarded request headers. - [ ] Validate and sanitize forwarded request headers.
- [ ] Persist sanitized API execution history, including timestamp, method, endpoint, status, duration, and error details. - [ ] Persist sanitized API execution history, including timestamp, method, endpoint, status, duration, and error details.
- [ ] Add a way to inspect basic execution history for troubleshooting. - [ ] Add a way to inspect basic execution history for troubleshooting.
### Validation and error handling ### Validation and error handling
- [ ] Validate the canonical project document on every save operation. - [ ] Validate the canonical project document on every save operation.
- [ ] Prevent invalid project documents from being persisted. - [ ] Prevent invalid project documents from being persisted.
- [ ] Add user-facing Preview error states for all supported component targets. - [ ] Add user-facing Preview error states for all supported component targets.
- [ ] Reconcile schema descriptions and examples with the canonical `project.bindings` response-mapping model. - [ ] Reconcile schema descriptions and examples with the canonical `project.bindings` response-mapping model.
- [ ] Remove new uses of the deprecated `action.responseMapping` field. - [ ] Remove new uses of the deprecated `action.responseMapping` field.
### Testing and release validation ### Testing and release validation
- [ ] Add backend tests for health, project CRUD, schema validation, and proxy input validation. - [ ] Add backend tests for health, project CRUD, schema validation, and proxy input validation.
- [ ] Add proxy integration tests for successful responses, upstream errors, malformed responses, and timeouts. - [ ] Add proxy integration tests for successful responses, upstream errors, malformed responses, and timeouts.
- [ ] Add persistence round-trip tests for complete canonical project documents. - [ ] Add persistence round-trip tests for complete canonical project documents.
- [ ] Add editor tests for every MVP component and its property controls. - [ ] Add editor tests for every MVP component and its property controls.
- [ ] Add end-to-end tests covering project creation, visual editing, action execution, response mapping, saving, and loading. - [ ] Add end-to-end tests covering project creation, visual editing, action execution, response mapping, saving, and loading.
- [ ] Run frontend and backend TypeScript checks successfully. - [ ] Run frontend and backend TypeScript checks successfully.
- [ ] Run all frontend and backend automated tests successfully. - [ ] Run all frontend and backend automated tests successfully.
- [ ] Run schema validation against every valid and invalid example successfully. - [ ] Run schema validation against every valid and invalid example successfully.
- [ ] Produce successful frontend and backend production builds. - [ ] Produce successful frontend and backend production builds.
- [ ] Run the full application through Docker Compose and complete the documented integration checklist. - [ ] Run the full application through Docker Compose and complete the documented integration checklist.
### MVP scope decision ### MVP scope decision
- [x] Decide that IBM Bob/watsonx and other AI integrations are not part of the initial MVP. - [x] Decide that IBM Bob/watsonx and other AI integrations are not part of the initial MVP.
- [x] Confirm that all five modeled authentication modes are required for MVP. - [x] Confirm that all five modeled authentication modes are required for MVP.
- [x] Define the six end-to-end MVP acceptance workflows in `MVP_SCOPE.md`. - [x] Define the six end-to-end MVP acceptance workflows in `MVP_SCOPE.md`.
### Documentation ### Documentation
- [ ] Designate the `docs/` copies of project documentation as authoritative and remove or redirect duplicate root-level copies. - [ ] Designate the `docs/` copies of project documentation as authoritative and remove or redirect duplicate root-level copies.
- [ ] Update the README documentation index to include the schema and response-mapping documents. - [ ] Update the README documentation index to include the schema and response-mapping documents.
- [ ] Update the build plan with the current implementation status and remaining milestone order. - [ ] Update the build plan with the current implementation status and remaining milestone order.
- [ ] Remove resolved questions from the requirements document or record their architectural decisions. - [ ] Remove resolved questions from the requirements document or record their architectural decisions.
- [ ] Publish a final MVP acceptance checklist tied to tested user workflows. - [ ] Publish a final MVP acceptance checklist tied to tested user workflows.
## Post-MVP Ideas ## Post-MVP Ideas
- [ ] Add multi-page application support. - [ ] Add multi-page application support.
- [ ] Add OAuth 2.0 and IBM Cloud IAM authentication. - [ ] Add OAuth 2.0 and IBM Cloud IAM authentication.
- [ ] Add OpenAPI import and generated forms/actions. - [ ] Add OpenAPI import and generated forms/actions.
- [ ] Add reusable templates, themes, and component libraries. - [ ] Add reusable templates, themes, and component libraries.
- [ ] Add version history, Git integration, and team collaboration. - [ ] Add version history, Git integration, and team collaboration.
- [ ] Add role-based access control and enterprise audit retention. - [ ] Add role-based access control and enterprise audit retention.
- [ ] Add tabs, modal dialogs, date pickers, file uploads, charts, and progress indicators. - [ ] Add tabs, modal dialogs, date pickers, file uploads, charts, and progress indicators.

View File

@ -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": {}
} }
} }

View File

@ -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;

View File

@ -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']);
});
}); });

View File

@ -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' }}>

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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"']);
});

View 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;
}

View File

@ -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;