Add page-load actions and component deletion safeguards

This commit is contained in:
Victor Wiebe 2026-07-30 09:43:30 -04:00
parent 9e4eb33e05
commit 86ca42ad12
28 changed files with 1552 additions and 46 deletions

View File

@ -192,9 +192,9 @@ 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.
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 passed on 2026-07-28.
### Pending manual request-input acceptance
### Manual request-input acceptance checklist (completed 2026-07-28)
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.
@ -214,8 +214,39 @@ Automated coverage verifies canonical response-binding and variable creation/edi
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.
Response-binding and variable manual acceptance remains pending. Docker Compose services and health passed on 2026-07-28; the prior WSL-integration blocker is resolved.
### Partial manual request-input checkpoint
### 2026-07-29 Boolean Variable Default Fix
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.
Manual typed-variable testing exposed a boolean editor defect: partial input was reset to false, so true could not be authored. The control was replaced by an explicit optional boolean selector. The focused Actions and Bindings tests passed at 2 suites / 14 tests, the full frontend suite passed at 15 suites / 477 tests, and the production build passed. The rebuilt frontend returned HTTP 200 and the user confirmed true is saved as a canonical JSON boolean. Typed-variable declarations and invalid-draft isolation are manually accepted; the rest of the response-binding/variable workflow remains pending.
Component response-binding manual acceptance passed on 2026-07-29. The initial httpbin.org endpoint intermittently returned 503 and correctly did not apply onSuccess. After switching to verified https://httpbingo.org/anything, the proxy returned HTTP 200 and the JSON Viewer displayed the mapped response body. Remaining response-binding/variable checks are pending. Variable response-binding manual acceptance also passed: the first response updated runtime statusText, the second request consumed it, and the canonical Not run default remained unchanged. Binding edit and selected deletion also passed, preserving the unrelated binding and restoring the add control. A deferred UI follow-up tracks clearer record boundaries and hierarchy in Actions and Bindings. Unsupported TextInput and ambiguous duplicate-name targets were manually confirmed excluded. Referenced-variable warning, cancellation, accepted deletion, and unresolved diagnostics also passed. Legacy onClick execution, diagnostics, and one-way migration to onSuccess also passed. Failure-state testing found a clipped Button error. The alert now renders below the fixed-height Button with wrapping, stale errors clear on retry/success, focused Preview tests passed at 2 suites/7 tests, the full suite at 15 suites/478 tests, and the production build passed. The user confirmed readable 503 handling, onSuccess suppression, clean canonical state, and stale-error clearing. Save/reload persistence and runtime reset passed, completing response-binding and variable manual acceptance on 2026-07-29.
### Manual request-input acceptance record
On 2026-07-20, the user confirmed that the item query-parameter row and guided Request value reference insertion into Query: item looked correct.
On 2026-07-28, Docker Compose startup, both services, and backend health passed. The user then confirmed that all remaining request-input checks passed: canonical component templates without inputMap, path-destination exclusion, execution with runtime component and variable values, ephemeral runtime-state separation, and continued raw-template editing. The complete request-input workflow is manually accepted.
## Slice 2 Page-Load Action Validation
On 2026-07-29, visual initial-page onLoad authoring and once-per-Preview initialization were implemented in the working tree. Editor coverage verifies add/change/clear behavior, unrelated-event preservation, duplicate normalization, immediate canonical updates, the no-action state, and missing-reference diagnostics. Preview integration coverage verifies once-per-mounted-initialization execution, re-entry behavior, request-template rendering from configured variable defaults, successful component and variable onSuccess delivery, failed-request suppression, missing actions, and canonical/runtime-state separation.
Validation under the documented ephemeral Node 20/npm 10 toolchain passed:
- Focused page-load and Button regression coverage: 4 suites / 9 tests.
- Full frontend suite: 18 suites / 486 tests, 0 snapshots.
- Frontend CRA production build: passed.
- Schema fixture matrix: 13 valid, 2 expected-invalid, and 2 diagnostic-invalid fixtures behaved as expected.
- Representative valid-visual-rest-actions.json: updated with canonical page onLoad configuration and validated.
- Backend build: not rerun because backend source and the shared schema were unchanged.
- Standalone frontend tsc --noEmit: retains the documented TypeScript 4.9 / @types/node 26 incompatibility and remains separate from the passing CRA compile.
Docker Compose rebuilt successfully on 2026-07-29. Both services started, the backend health endpoint returned status ok, and the frontend responded on port 3000. Proportional manual page-load acceptance passed on 2026-07-29. The accepted Table workflow exposed a non-blocking layout limitation: the white Preview page/canvas background does not grow with runtime-rendered rows beyond its configured or minimum dimensions. Responsive Preview output sizing and Table pagination design are tracked separately in TASKS.md. Final Slice 2 acceptance remains pending.
## Slice 2 Component Deletion Safety Validation
Manual baseline testing on 2026-07-29 through 2026-07-30 confirmed that deleting a referenced Table or Text Input happened immediately without warning, while the retained binding and request template correctly produced missing-component diagnostics.
The working tree now detects canonical binding source/target paths and action request-template references before component deletion. Referenced deletion requires explicit cancellation or confirmation, confirmed deletion preserves dangling configuration for diagnostics, and unreferenced deletion remains immediate.
Focused tests passed at 4 suites / 12 tests, the full frontend suite passed at 20 suites / 490 tests, and the frontend production build passed. The user manually confirmed every UI retest in `SAFETY_TEST_PLAN.md` passed on 2026-07-30.

View File

@ -19,11 +19,12 @@ Use these files in this order:
1. Current source code and automated tests
2. `TASKS.md` for completed and remaining work
3. `docs/response-mapping-model.md` for response-binding decisions
4. `shared/schemas/conductor-project.schema.json` for the accepted project-document shape
5. `docs/REQUIREMENTS.md` for product scope
6. `docs/ARCHITECTURE.md` for architectural direction
7. `docs/BUILD_AND_TEST_PLAN.md` for historical milestone and validation guidance
3. `TESTING.md` for pending and accepted manual workflows
4. `docs/response-mapping-model.md` for response-binding decisions
5. `shared/schemas/conductor-project.schema.json` for the accepted project-document shape
6. `docs/REQUIREMENTS.md` for product scope
7. `docs/ARCHITECTURE.md` for architectural direction
8. `docs/BUILD_AND_TEST_PLAN.md` for historical milestone and validation guidance
Some root-level documentation duplicates files under `docs/`. The `docs/` copies should become authoritative, but that cleanup has not yet been completed.
@ -104,7 +105,7 @@ All eleven schema-supported MVP components are available in the palette and have
- Secure credential storage and secret resolution are not implemented.
- The REST proxy still needs endpoint allowlisting, SSRF protection, and stricter header and URL validation.
- Sanitized execution history and troubleshooting views are not implemented.
- Visual REST action, Button event, request-input, response-binding, and variable authoring are implemented; page-load actions still rely on JSON editing.
- Visual REST action, Button event, request-input, response-binding, variable, and page onLoad authoring are implemented. Page-load manual acceptance passed on 2026-07-29.
- Project documents are validated in the JSON Editor, but save operations still need an unconditional validation gate.
- Backend and end-to-end automated test coverage remains incomplete.
- IBM Bob/watsonx will not be used; AI is provider-neutral and post-MVP.
@ -133,8 +134,8 @@ The initial repository push was completed on 2026-07-18.
Unless the user chooses a different priority, proceed in this order:
1. Complete the pending manual acceptance for request-input authoring and the proportional response-binding/variable workflow.
2. Add visual page-load action configuration.
1. Complete final Slice 2 workflow-launcher and dependent-data acceptance; request-input, response-binding/variable, and page-load acceptance passed by 2026-07-29.
2. Complete final Slice 2 workflow-launcher and dependent-data acceptance.
3. Add validation to every project save path.
4. Implement authentication and secure secret handling.
5. Harden the REST proxy and add sanitized execution logging.
@ -209,7 +210,7 @@ Date: 2026-07-20
- Added pure canonical-update and UI interaction coverage.
- 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.
- Manual request-input acceptance remains pending.
- Manual request-input acceptance passed on 2026-07-28, including executed component and variable references, canonical/runtime-state separation, destination filtering, and raw-template regression coverage.
- `NEXT_SESSION_PROMPT.md` contains the continuation scope and manual request-input acceptance checklist.
### Response Binding and Variable Addendum
@ -221,10 +222,37 @@ Date: 2026-07-20
- 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.
- Docker Compose services and health checks passed on 2026-07-28. Request-input manual acceptance passed; response-binding/variable manual acceptance remains pending.
### Boolean Variable Default Fix
- On 2026-07-29, manual typed-variable acceptance found that entering true was reset to false before it could be completed.
- Replaced the free-form boolean default editor with an explicit No default, true, and false selector.
- Extended the Actions and Bindings interaction test to assert a canonical boolean true default.
- Focused validation passed at 2 suites / 14 tests; the full frontend suite passed at 15 suites / 477 tests; the production build passed.
- The user manually confirmed boolean true authoring after the rebuilt frontend was deployed. Typed declarations and invalid-draft isolation are accepted; the remaining response-binding and variable workflow is pending.
- Component response-binding manual acceptance passed on 2026-07-29: a successful HTTP 200 response body populated the JSON Viewer through a canonical onSuccess binding. Intermittent httpbin.org 503 responses correctly did not apply the binding; httpbingo.org was used for the successful checkpoint. Variable response-binding manual acceptance also passed: a first response updated runtime statusText, a second request consumed that runtime value, and the canonical Not run default remained unchanged. Binding edit and selected deletion also passed, preserving the unrelated binding and re-enabling Add response binding. A deferred Actions and Bindings information-architecture task was added after the user reported that records are difficult to distinguish and scan. Unsupported TextInput targets and ambiguous duplicate-name targets were manually confirmed absent from new-binding choices. Referenced-variable warning, cancellation, accepted deletion, and unresolved-reference diagnostics also passed without changing unrelated records. Legacy onClick execution, diagnostics, and one-way migration to onSuccess also passed; neither migrated nor new records offered onClick afterward. Manual failure-state testing exposed a clipped Button error; the alert is now positioned below the fixed-height Button with wrapping, and stale errors clear on retry or success. Focused Preview tests passed at 2 suites/7 tests, the full frontend suite at 15 suites/478 tests, and the production build passed. The user confirmed readable 503 handling, onSuccess suppression, clean canonical state, and stale-error clearing. Save/reload persistence and runtime reset passed, completing response-binding and variable manual acceptance on 2026-07-29.
- Page-load action authoring is implemented in the working tree and proportional manual acceptance passed on 2026-07-29.
### 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.
- On 2026-07-28, the user completed and accepted the remaining Preview execution, outgoing-request inspection, runtime-state separation, raw-template regression, path-destination exclusion, and variable-resolution checks. The complete request-input workflow is accepted.
### Page-Load Action Addendum
- Implemented visual initial-page onLoad action selection and clearing in the Visual Editor, with immediate canonical page.events synchronization, unrelated-event preservation, duplicate normalization, and missing-action diagnostics.
- Preview executes the initial page's first onLoad action once per mounted initialization and executes once again after leaving and re-entering Preview.
- The runtime reuses request-template rendering, anonymous proxy execution, normalized responses, and top-level response bindings for supported component and variable onSuccess targets.
- Page-load status, loading, errors, responses, component values, and runtime variable values remain ephemeral. No ComponentEvent.inputMap, deprecated action.responseMapping, authentication, secrets, or general orchestration was added.
- Updated valid-visual-rest-actions.json with representative page-load configuration. No schema, frontend model, or backend changes were required.
- Focused tests pass at 4 suites / 9 tests; the full frontend suite passes at 18 suites / 486 tests under Node 20/npm 10. The frontend production build and 13 valid / 2 expected-invalid / 2 diagnostic-invalid schema matrix pass.
- Docker Compose rebuilt successfully; both services started, backend health passed, and the frontend responded on port 3000. Proportional manual page-load acceptance passed on 2026-07-29. The accepted Table workflow exposed a non-blocking Preview background-sizing limitation; TASKS.md tracks content-aware Preview sizing and a separate Table-pagination design discussion. No commit or push was made.
### Component Deletion Safety Addendum
- Added reference-aware deletion warnings for components used by canonical response-binding source or target paths and by action URL, header, query, path, or body templates.
- The warning names the affected binding or action and requires **Cancel** or **Delete anyway**. Confirmed deletion removes only the component so existing diagnostics can expose the dangling reference.
- Unreferenced components retain immediate deletion.
- Focused validation passed at 4 suites / 12 tests, the full frontend suite at 20 suites / 490 tests, and the production build passed.
- The user manually confirmed every component-deletion safety retest in `SAFETY_TEST_PLAN.md` passed on 2026-07-30. No commit or push was made.

View File

@ -81,6 +81,7 @@ docker-compose up --build
| Document | Description |
|---|---|
| [TESTING.md](TESTING.md) | Detailed manual test workflows and acceptance records |
| [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) | Full product requirements |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Technical architecture |
| [docs/NICE-TO-HAVE.md](docs/NICE-TO-HAVE.md) | Future enhancements |

144
SAFETY_TEST_PLAN.md Normal file
View File

@ -0,0 +1,144 @@
# Component Deletion Safety Test Plan
## Purpose
Verify that deleting a referenced component is safe, warns before damage, and leaves useful diagnostics when deletion is confirmed.
Use a disposable project. Save it before each deletion test so the working version can be restored without rebuilding it.
## Test 1: Table Referenced by a Response Binding
### Setup
1. Confirm the project contains a Table named `postsTable`.
2. In **Actions & Bindings**, confirm a response binding targets `postsTable (Table.rows)`.
3. Save the project.
Why: deleting `postsTable` would break a real canonical response-binding target.
### Attempt deletion
1. Open **Visual Editor**.
2. Select `postsTable`.
3. Select the Table's red **x**.
Expected warning:
- The UI says `postsTable` is referenced.
- It identifies the affected response binding.
- It offers **Cancel** and **Delete anyway**.
### Stop condition
If the Table disappears immediately without a warning, stop this test and record:
> Referenced Table deleted without a warning.
This confirms the deletion-warning implementation gap. Continue only with **Post-deletion diagnostic** below.
### Cancel path
If a warning appears:
1. Select **Cancel**.
2. Confirm `postsTable` remains on the canvas.
3. Confirm the response binding remains unchanged.
Why: cancellation must not partially modify the component or binding.
### Confirmed deletion path
1. Attempt the deletion again.
2. Select **Delete anyway**.
3. Confirm only `postsTable` was removed.
Why: intentional deletion must remain possible without changing unrelated records.
### Post-deletion diagnostic
1. Open **Actions & Bindings**.
2. Find the binding that targeted `postsTable`.
3. Confirm it reports that target component `postsTable` is missing.
4. Open **JSON Editor** and confirm:
- `postsTable` is absent from `page.components`.
- The binding still targets `components.postsTable.rows`.
- Unrelated actions, bindings, variables, and components are unchanged.
Why: broken references must remain visible and diagnosable rather than being silently removed.
### Restore
1. Do not save the broken project.
2. Use **Load** to reopen the saved project.
3. Confirm `postsTable` and its binding return.
4. Enter **Preview** and confirm page-load population still works.
## Test 2: Text Input Referenced by a Request Template
### Setup
1. Add a Text Input named `itemInput`.
2. In **Actions & Bindings**, edit or create a disposable GET action.
3. Add this query parameter:
- Key: `item`
- Value: `{{components.itemInput.value}}`
4. Save the action and project.
Why: the action now depends on `itemInput` when rendering its request.
### Attempt deletion
1. Open **Visual Editor**.
2. Select `itemInput`.
3. Select its red **x**.
Expected warning:
- The UI says `itemInput` is referenced by an action request template.
- It offers **Cancel** and **Delete anyway**.
If no warning appears, record:
> Referenced Text Input deleted without a warning.
### Diagnostic after confirmed deletion
1. Open **Actions & Bindings**.
2. Inspect the affected action.
3. Confirm it reports that the template references missing component `itemInput`.
4. Confirm unrelated configuration remains unchanged.
5. Reload the saved project to restore the working version.
## Results
- [ ] Referenced Table deletion produced a warning. **Failed: the Table was deleted immediately without a warning.**
- [ ] The Table warning identified the affected binding. **Not applicable: no warning appeared.**
- [ ] Cancel preserved the Table and binding. **Not applicable: no warning appeared.**
- [ ] Delete anyway removed only the Table. **Not applicable: deletion was immediate.**
- [x] The broken binding produced a missing-component diagnostic.
- [ ] Referenced Text Input deletion produced a warning. **Failed: itemInput was deleted immediately without a warning.**
- [ ] The Text Input warning identified the affected request template. **Not applicable: no warning appeared.**
- [x] The broken template produced a missing-component diagnostic.
- [x] Reload restored the saved working project.
Tester/date: User, 2026-07-29 through 2026-07-30
Notes or defects: Deleting postsTable did not show a reference warning even though binding_response_1 targeted components.postsTable.rows. After deletion, Actions & Bindings correctly retained the binding and reported component postsTable not found / Target references component postsTable which does not exist on any page. Deleting itemInput also produced no warning, but the retained action correctly diagnosed the missing component template afterward. After restoration, the query row correctly placed the template in the value field; queryParameters.key identifies the query parameter named key rather than the editor's key field. The separate action-trigger warning remains a false positive because it ignores the valid page onLoad event.
## Implementation Retest
The missing warning is now implemented and automated-tested. Repeat these checks in the rebuilt app:
- [x] Delete referenced `postsTable`; the dialog lists its response binding.
- [x] Choose **Cancel**; the Table and binding remain.
- [x] Try again and choose **Delete anyway**; only the Table is removed, and the retained binding reports the missing component.
- [x] Delete referenced `itemInput`; the dialog lists the action request-template reference.
- [x] Choose **Cancel**; the Text Input and action remain.
- [x] Try again and choose **Delete anyway**; only the Text Input is removed, and the retained action reports the missing component.
- [x] Delete an unreferenced disposable component; it is removed immediately without an unnecessary dialog.
Why: this proves both safe interruption and intentional deletion work, while broken references remain visible instead of being silently erased.
Automated checkpoint on 2026-07-30: focused coverage passed at 4 suites / 12 tests, the full frontend suite passed at 20 suites / 490 tests, and the production build passed. The user manually confirmed every implementation-retest check passed on 2026-07-30.

View File

@ -2,7 +2,7 @@
## Status
In progress — response bindings and variable authoring complete; manual acceptance and page-load actions next
In progress - visual configuration and page-load manual acceptance are complete; final Slice 2 acceptance remains
## Objective
@ -36,11 +36,11 @@ Allow an MVP project to be configured through the GUI without routine hand-editi
- [x] Implement request input authoring through executed REST request templates; do not write inert `ComponentEvent.inputMap`.
- [x] Implement response source and component/variable target selection.
- [x] Default new action-response bindings to `onSuccess` without offering legacy `onClick` for new records.
- [ ] Implement page-load action configuration for initial data population.
- [x] Implement page-load action configuration for initial data population.
- [x] Add variable declaration and typed default-value editing.
- [ ] Warn before deleting referenced actions, variables, or components. Action and variable deletion are covered; component deletion remains.
- [x] Warn before deleting referenced actions, variables, or components. Referenced component deletion requires explicit confirmation and preserves dangling references for diagnostics; manual acceptance passed on 2026-07-30.
- [x] Ensure every REST action visual edit immediately updates canonical JSON while invalid drafts remain local.
- [ ] 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.
- [ ] Add slice-wide tests, examples, and documentation updates. Coverage, the representative fixture, and handoff docs are complete through page-load actions; final manual slice acceptance remains.
## Implementation Order
@ -63,9 +63,9 @@ Each increment writes only canonical configuration into the shared project docum
## Validation
- [ ] 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 response-binding and variable increment.
- [x] Editor interaction and Preview dispatch tests pass through the response-binding and variable increment.
- [x] The representative canonical JSON validates through the response-binding and variable increment.
- [x] Frontend production build passes through the page-load increment.
- [x] Editor interaction and Preview dispatch tests pass through the page-load increment.
- [x] The representative canonical JSON validates through the page-load increment.
- [x] Increment 1 manual REST action authoring and referenced-action deletion workflow passes.
- [ ] Manual workflow-launcher and dependent-data scenarios pass.
@ -92,10 +92,10 @@ Each increment writes only canonical configuration into the shared project docum
## Handoff
- Last completed: Visual response-binding and variable authoring, with automated validation.
- Next action: Complete manual request-input and response-binding/variable acceptance, then implement visual page-load action authoring.
- 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`.
- Last completed: Visual page-load action authoring and Preview initialization, with automated validation.
- Next action: Complete final Slice 2 workflow-launcher and dependent-data acceptance. Track responsive Preview output sizing and Table pagination as explicit follow-ups.
- Known blockers: Standalone frontend `tsc` retains its recorded tooling limitation.
- Current pushed baseline: `9e4eb33 Add visual response bindings and variables`.
- Continuation and manual-test instructions: `NEXT_SESSION_PROMPT.md`.
## Response Binding and Variable Increment
@ -111,8 +111,28 @@ Implemented and audited through 2026-07-20 in the working tree:
- 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.
Manual acceptance is separate from automated coverage. The request-input and response-binding/variable workflows passed manual acceptance by 2026-07-29.
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.
Detailed manual procedures and result checklists are maintained in `TESTING.md`.
Next increment: page-load action authoring.
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`. On 2026-07-28, the user confirmed all remaining request-input checks passed: Preview execution, outgoing-request inspection, runtime-state separation, destination exclusion, raw-template regression, and variable resolution. Full request-input manual acceptance is recorded.
### 2026-07-29 Boolean Default Defect
Manual typed-variable testing found and confirmed a defect: the boolean default text editor forced partial input to false, preventing true from being authored. The editor now offers No default, true, and false explicitly. Focused tests, all 15 frontend suites / 477 tests, and the frontend production build pass. The user confirmed the fix. Typed declarations and invalid-draft isolation are accepted; remaining response-binding/variable manual checks are pending.
Component response-binding manual acceptance passed on 2026-07-29. Intermittent httpbin.org 503 responses correctly did not trigger onSuccess; the verified httpbingo.org endpoint returned HTTP 200 and populated the JSON Viewer. Remaining response-binding/variable checks are pending. Variable response-binding manual acceptance also passed using a two-request runtime-state check while preserving the canonical default. Binding edit and selected deletion also passed. A deferred UI follow-up now tracks clearer visual separation and hierarchy for action, binding, and variable records. Unsupported and ambiguous response targets were manually confirmed excluded. Referenced-variable deletion warnings and post-deletion diagnostics also passed. Legacy onClick execution and one-way migration to onSuccess also passed. Failure and ephemeral-state acceptance passed after fixing clipped Button errors; focused Preview tests passed at 2 suites/7 tests, all 15 frontend suites/478 tests passed, and the production build passed. Save/reload persistence and runtime reset passed, completing response-binding/variable manual acceptance.
## Page-Load Action Increment
Implemented in the working tree on 2026-07-29:
- The Visual Editor page bar selects or clears an existing anonymous REST action for the initial page's onLoad event.
- Canonical page.events updates immediately, preserves unrelated events, removes duplicate onLoad entries, and reports missing action references.
- Preview executes the initial page's first onLoad action once per mounted Preview initialization. Leaving and re-entering Preview creates a fresh ephemeral runtime and executes once again.
- Page load reuses request-template rendering, anonymous proxy execution, normalized responses, and canonical top-level project.bindings, including component and variable onSuccess targets.
- Preview exposes loading, success, and failure status without writing loading, errors, responses, component values, or runtime variable values to canonical JSON.
- The representative visual REST fixture includes a page onLoad event; no schema or type migration was required.
- Focused validation passes at 4 suites / 9 tests. The full frontend suite passes at 18 suites / 486 tests under Node 20/npm 10, the CRA production build passes, and the 13 / 2 / 2 schema matrix remains green.
- Backend code and schema were not changed. Standalone tsc --noEmit retains the documented TypeScript 4.9 / @types/node 26 incompatibility.
Manual page-load acceptance passed on 2026-07-29 and is recorded in TESTING.md. The screenshot exposed a non-blocking Preview page-sizing limitation for runtime-expanded Tables; responsive Preview output sizing and a separately designed Table pagination capability are tracked in TASKS.md. Final Slice 2 acceptance remains pending.

View File

@ -75,7 +75,12 @@ This document summarizes the current implementation and the remaining work requi
- [x] Implement visual component/variable request-input references through executed REST request templates.
- [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.
- [ ] Revisit Actions and Bindings information architecture after Slice 2 acceptance: visually separate action, binding, and variable records with clearer card boundaries, hierarchy, spacing, and scannable summaries so the page does not read like a continuous encyclopedia-style text stream.
- [x] Implement visual page onLoad action selection, canonical synchronization, missing-reference diagnostics, once-per-Preview initialization, and onSuccess delivery to component and variable targets.
- [x] Warn before deleting components referenced by response bindings or action request templates, with explicit Cancel and Delete anyway choices while preserving dangling references for diagnostics. Manual UI acceptance passed on 2026-07-30.
- [ ] Update action and response-binding diagnostics to treat page onLoad events as valid action triggers, eliminating the false no-component-event / binding-will-never-receive-a-response warnings and using clear component-or-page-event wording.
- [ ] Make the white Preview page/canvas background grow with runtime-rendered output, including Tables whose bound row content exceeds the configured design-time component or canvas height.
- [ ] Add Table pagination after a dedicated design discussion covering client-side versus server-side pagination, data volume, request/response contracts, page-size controls, loading/error behavior, and canonical configuration.
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
### Authentication and secrets

541
TESTING.md Normal file
View File

@ -0,0 +1,541 @@
# Conductor Manual Testing
This document is the durable manual-testing checklist for Conductor. Automated
test results belong in `BASELINE.md`; this file covers behavior that needs a
person to verify in the running application.
Do not mark a workflow accepted from automated coverage alone. Record the date,
tester, environment, and any observations in the result block for that workflow.
## Status Summary
| Workflow | Status |
|---|---|
| Slice 1 components and editor synchronization | Accepted 2026-07-18 |
| Anonymous REST action authoring and referenced deletion | Accepted 2026-07-19 |
| Visual Button `onClick` assignment | Accepted 2026-07-19 |
| Guided request-input query insertion | Accepted 2026-07-20 |
| Complete request-input execution | Accepted 2026-07-28 |
| Response bindings and variable authoring | Accepted 2026-07-29 |
| Page-load actions | Accepted 2026-07-29 |
| Final Slice 2 end-to-end acceptance | Pending |
## Test Environment Setup
### 1. Start Docker Desktop
1. Start Docker Desktop on the host computer.
2. If using WSL, open Docker Desktop settings.
3. Select **Resources**, then **WSL Integration**.
4. Enable integration for the WSL distribution containing this repository.
5. Apply the changes and restart Docker Desktop if prompted.
6. From the repository root, verify that Docker is available:
```bash
docker info
```
Expected result: the command displays client and server information without a
connection or WSL-integration error.
### 2. Start Conductor
From the repository root, run:
```bash
docker compose up --build
```
Leave this terminal running. In another terminal, run:
```bash
docker compose ps
curl http://localhost:4000/api/health
```
Expected results:
- The `frontend` and `backend` services are running.
- The health request returns JSON containing `"status":"ok"`.
- `http://localhost:3000` opens the Conductor application.
If startup fails, capture `docker compose ps` and:
```bash
docker compose logs backend frontend
```
Do not continue until both services and the health endpoint work.
## Canonical JSON and Runtime State
Several workflows ask you to inspect canonical JSON:
1. Select **JSON Editor** in the application navigation.
2. Locate the relevant section under `project`, such as `actions`, `bindings`,
or `variables`.
3. Do not add runtime-only fields merely to test them.
Runtime values are expected to exist only while Preview is running. Canonical
JSON must not gain fields containing:
- current input or selection values;
- loading flags;
- action responses;
- action or component errors;
- updated runtime variable values.
Configured component defaults and declared variable defaults are canonical and
are expected to remain in JSON.
## Test 1: Request-Input Authoring and Execution
Purpose: verify that a current component or variable value can be inserted into
an executed REST request template without using compatibility-only `inputMap`.
Partial status: adding the `item` query row and guided component insertion was
confirmed on 2026-07-20. The remaining steps still need acceptance.
### A. Create the test project
1. Open `http://localhost:3000`.
2. Create a new project, or open a disposable test project.
3. In **Visual Editor**, add a **Text Input**.
4. Select the Text Input and give it a unique component name such as
`itemInput`.
5. Add a **Button** and name it `lookupButton`.
6. Optionally add a **JSON Viewer** named `requestResult`; it will be useful in
the response-binding test.
7. Open **Actions & Bindings**.
8. Select **Add REST action**.
9. Configure:
- Name: `Look up item`
- Method: `GET`
- URL: `https://httpbin.org/anything`
- Authentication: confirm it remains **Anonymous**
10. Finish the action edit, then return to **Visual Editor**.
11. Select `lookupButton` and assign `Look up item` as its `onClick` action.
Expected result: JSON contains a Button event referencing the action ID, but no
`inputMap` is created.
### B. Add the guided query reference
1. Return to **Actions & Bindings**.
2. Edit `Look up item`.
3. Find **Query parameters** and select **Add row**.
4. Enter `item` in the key field. The value may initially remain blank.
5. Find **Request value reference** in the same action editor.
6. Select `itemInput (TextInput)` as the source.
7. Select `Query: item` as the destination.
8. Select **Insert reference**.
9. Return to the Query parameters section.
Expected result: the value of `item` is:
```text
{{components.itemInput.value}}
```
10. Open **JSON Editor** and find the action's `queryParameters`.
Expected canonical fragment:
```json
"queryParameters": {
"item": "{{components.itemInput.value}}"
}
```
11. Search the document for `inputMap`.
Expected result: no `ComponentEvent.inputMap` was added.
12. Return to the action editor and inspect the Request value reference
destination choices.
Expected result: URL, header, query, and body destinations may be offered. Path
parameters are not offered.
### C. Execute the component reference
1. Open **Preview**.
2. Enter a recognizable value in the Text Input, for example:
```text
manual-test-742
```
3. Select the Button once.
4. Wait for the request to complete.
5. Inspect the response or the browser Network panel. For the httpbin endpoint,
the response body should echo the request URL or query arguments.
Expected result: the outgoing URL contains an encoded query parameter equivalent
to:
```text
?item=manual-test-742
```
6. While still in the same project, open **JSON Editor** again.
Expected results:
- The template remains `{{components.itemInput.value}}`; it was not replaced by
`manual-test-742`.
- No loading, response, error, or current component value was added to JSON.
### D. Verify raw template editing still works
1. Edit `Look up item` again.
2. Add a raw URL suffix, header value, or second query parameter containing
literal text or a supported template.
3. Add a body template temporarily if desired. A GET body need not be executed;
this step checks that the raw editor remains usable.
4. Confirm the raw value synchronizes to JSON.
5. Confirm the previously inserted `item` query reference is unchanged.
Expected result: guided insertion does not disable or overwrite subsequent raw
URL, header, query, or body editing.
### E. Verify a variable request reference
1. In **Actions & Bindings**, create a variable:
- Name: `testRegion`
- Type: `string`
- Default value: `east`
2. Edit `Look up item`.
3. Add a query parameter named `region`.
4. In **Request value reference**, choose variable `testRegion`.
5. Choose `Query: region`, then select **Insert reference**.
6. Confirm JSON contains:
```text
{{variables.testRegion}}
```
7. Execute the Button in Preview.
Expected result: the outgoing query includes `region=east`, while canonical JSON
retains the variable declaration and default rather than a runtime copy.
### Request-input result
- [x] Docker services and health endpoint passed.
- [x] Guided `item` query-reference insertion looked correct (2026-07-20).
- [x] Canonical component template and absence of `inputMap` confirmed.
- [x] Path parameters confirmed absent from guided destinations.
- [x] Runtime component value appeared in the outgoing request.
- [x] Runtime state remained absent from canonical JSON.
- [x] Raw template editing regression passed.
- [x] Variable reference resolved in an executed request.
Tester/date: User, 2026-07-28
Notes or defects: All request-input checks passed. The tester initially looked
below the query-parameter editor for Request value reference; the control is at
the top of the expanded Request parameters section. No product defect found.
## Test 2: Response Bindings and Variables
Purpose: verify visual CRUD for canonical `project.bindings` and
`project.variables`, response delivery in Preview, legacy compatibility, and
runtime-state separation.
### A. Test typed variable declarations
1. Open the test project in **Actions & Bindings**.
2. In **Variables**, select **Add variable**.
3. Create `statusText`:
- Type: `string`
- Default: `Not run`
- Description: `Latest manual test status`
4. Create `retryCount` with type `number` and default `2`.
5. Create `enabled` with type `boolean` and default `true`.
6. Create `metadata` with type `object` and default:
```json
{"source":"manual"}
```
7. Create `items` with type `array` and default:
```json
[]
```
8. Inspect **JSON Editor**.
Expected result: `project.variables` contains typed declarations. Number and
boolean defaults are JSON numbers and booleans, not quoted strings; object and
array defaults have the correct JSON shapes.
9. Edit `metadata`, replace its default with malformed JSON, and try to save.
Expected result: an actionable validation message appears and the invalid draft
does not replace the canonical variable value.
10. Correct the JSON and save it.
Expected result: the corrected default updates canonical JSON.
### B. Create and execute a component response binding
1. Ensure the project contains `Look up item` and a JSON Viewer named
`requestResult`.
2. Ensure `lookupButton` runs `Look up item` on click.
3. In **Actions & Bindings**, select **Add response binding**.
4. Select `Look up item` as the REST action response source.
5. Set Response path to `body` to map the upstream body, or leave it blank to
map the complete normalized response envelope.
6. Select `requestResult (JsonViewer.value)` as the target.
7. Inspect **JSON Editor**.
Expected results:
- The new record is in top-level `project.bindings`.
- Its source begins `actions.<actionId>.response`.
- Its target is `components.requestResult.value`.
- Its trigger is exactly `onSuccess`.
- No `action.responseMapping` was added.
8. Open **Preview**, enter a value in `itemInput`, and select the Button.
Expected result: after a successful request, the JSON Viewer displays the mapped
response value.
### C. Create and execute a variable response binding
1. Return to **Actions & Bindings**.
2. Add another response binding.
3. Select `Look up item` as the source action.
4. Use a response path appropriate to the httpbin response, such as `body.url`.
5. Select `statusText (variable)` as the target.
6. Confirm the new binding uses `onSuccess`.
7. Execute the Button successfully in Preview.
8. Execute any request that consumes `{{variables.statusText}}`, or otherwise
observe its downstream behavior.
Expected results:
- The runtime variable receives the mapped response value.
- `project.variables.statusText.defaultValue` remains `Not run` in canonical
JSON.
- No runtime variable value is saved into canonical JSON.
### D. Edit and delete bindings
1. Edit the component response binding.
2. Change its response path, save/finish the edit, and confirm JSON updates.
3. Change its supported target and confirm JSON updates.
4. Delete that binding and accept the confirmation.
Expected results:
- Edits update only the selected top-level binding.
- Deletion removes only that binding.
- **Add response binding** remains enabled afterward.
### E. Verify target restrictions
1. Add or select an unsupported input component such as Text Input.
2. Start adding or editing a response binding and inspect Target choices.
Expected result: unsupported targets such as `TextInput.value` are not offered.
3. If two components have the same name, inspect the Target choices again.
Expected result: the ambiguous duplicate component name is not offered as a new
target, and existing ambiguous bindings receive a diagnostic.
### F. Verify variable reference warnings
1. Ensure `statusText` is referenced by a response binding or request template.
2. Select **Delete** for `statusText`.
Expected result: the confirmation identifies the binding or action references
that would become unresolved.
3. Cancel and confirm the variable remains.
4. Repeat deletion and accept it only in a disposable project.
Expected result: diagnostics identify the now-unresolved references; unrelated
variables and bindings remain unchanged.
### G. Verify legacy `onClick` compatibility
This test needs a pre-existing legacy action-response binding authored through
JSON. Do not use the visual editor to create a new legacy binding.
1. In a disposable project, use **JSON Editor** to add an action-response binding
with `"trigger": "onClick"` and apply the valid JSON.
2. Open **Actions & Bindings** and inspect the binding.
Expected result: it is displayed with an informational legacy diagnostic and can
still execute for a supported component target.
3. Edit the legacy binding.
Expected result: `onClick` is shown only because this record is already legacy.
4. Change the trigger to **On success**.
Expected results:
- Canonical JSON changes to `"trigger": "onSuccess"`.
- The editor no longer offers `onClick` for that migrated record.
- A newly added response binding never offers `onClick`.
### H. Verify failure and ephemeral state
1. Temporarily change the action URL to a URL that will fail or return an error.
2. Execute the Button in Preview.
Expected results:
- The UI exposes a usable loading state followed by an error state.
- `onSuccess` response bindings do not apply a failed response.
- Loading flags, errors, and responses do not appear in canonical JSON.
3. Restore the valid action URL.
### I. Verify save and reload
1. Save the project.
2. Reload it from the project picker.
3. Reopen **Actions & Bindings**.
4. Confirm actions, Button events, request templates, response bindings, and
variable declarations/defaults are preserved.
5. Open **Preview** and execute the successful workflow again.
Expected result: configured behavior survives persistence, while previous
runtime responses, errors, loading state, input values, and modified runtime
variables do not.
### Response-binding and variable result
- [x] Typed variable declarations/defaults passed.
- [x] Invalid variable draft remained outside canonical JSON.
- [x] Component response binding executed with `onSuccess`.
- [x] Variable response binding executed without changing its canonical default.
- [x] Binding edit and delete passed.
- [x] Unsupported and ambiguous targets were excluded.
- [x] Referenced-variable deletion warning passed.
- [x] Legacy `onClick` compatibility and one-way migration passed.
- [x] Failure and ephemeral-state checks passed.
- [x] Save/reload persistence passed.
Tester/date: User, 2026-07-29
Notes or defects: Typed declarations, invalid-draft isolation, component onSuccess mapping, and variable onSuccess mapping passed. The two-request variable check proved runtime statusText changed while its canonical Not run default remained intact. Boolean true authoring initially failed and was fixed with an explicit selector. The original httpbin.org endpoint intermittently returned 503; onSuccess correctly withheld those failures. The verified httpbingo.org endpoint produced successful binding results. Binding edit and selected deletion also passed, with the other binding preserved and Add response binding re-enabled. The tester requested a later Actions and Bindings UI revisit because records read as a continuous text stream rather than distinct scannable items. Unsupported TextInput targets and ambiguous duplicate component-name targets were also excluded as expected. Referenced-variable warning, cancellation, accepted deletion, and unresolved-reference diagnostics also passed without changing unrelated records. Legacy onClick execution, informational diagnostics, and one-way migration to onSuccess also passed; new records did not offer onClick. Failure and ephemeral-state checks passed after fixing the clipped Button error: the complete 503 message is readable, onSuccess withheld the failed response, canonical state remained clean, and a later success cleared the stale error. Save/reload preserved canonical configuration, reset all prior runtime state, and the successful workflow executed after reload. Complete response-binding and variable manual acceptance passed.
## Test 3: Page-Load Actions
Status: accepted by the user on 2026-07-29 after the rebuilt Docker Compose workflow passed.
Execute this workflow against the rebuilt Docker Compose application before
declaring the page-load increment or Slice 2 accepted:
1. Create an anonymous REST action that returns Dropdown options or Table rows.
2. Assign the existing action to the page's visual `onLoad` configuration.
3. Add an `onSuccess` response binding to the Dropdown or Table.
4. Inspect JSON and confirm only canonical page event, action, and binding
configuration was added.
5. Enter Preview and verify the action executes once when Preview initializes.
6. Verify loading, successful population, empty results, and failure behavior.
7. Leave and re-enter Preview and verify initialization is predictable and does
not write runtime values into canonical JSON.
8. Save and reload the project and repeat the test.
9. Delete the referenced action after accepting its reference warning, or use JSON Editor to create a dangling reference, and verify the Visual Editor shows an actionable missing-action diagnostic.
10. Repeat with an empty successful response and confirm the target presents a usable empty state rather than stale prior data.
### Page-load result
- [x] Visual `onLoad` assignment passed.
- [x] Initial successful population passed.
- [x] Loading, empty, and failure states passed.
- [x] Runtime state remained ephemeral.
- [x] Save/reload passed.
- [x] Missing-reference diagnostics passed.
Tester/date: User, 2026-07-29
Notes or defects: Test 3 passed. The populated Table exposed a non-blocking Preview layout limitation: the white page/canvas background retains its configured or minimum size instead of growing with runtime-rendered Table output. TASKS.md now tracks content-aware Preview sizing. It also tracks Table pagination as a separate design item that requires discussion of client-side versus server-side pagination before implementation.
Automated checkpoint: 4 focused suites / 9 tests, 18 full frontend suites / 486 tests, frontend production build, and the 13 / 2 / 2 schema matrix passed under Node 20/npm 10 on 2026-07-29. Docker Compose rebuilt, both services started, backend health passed, and the frontend responded on port 3000. Automation and smoke checks do not mark this workflow accepted.
## Final Slice 2 Acceptance
Run this only after Tests 13 pass and component-deletion diagnostics are
complete.
### Workflow launcher
1. Build a small form with at least one input, a Button, and a response display.
2. Configure an anonymous action, request reference, Button event, and response
binding entirely through visual controls.
3. Execute it successfully in Preview.
4. Trigger a failure and confirm usable error behavior.
5. Save, reload, and execute it again.
### Dependent data
1. Populate a Dropdown through a page-load action and response binding.
2. Select a Dropdown value.
3. Use that selection in a second Button-triggered request.
4. Map the second response to another component or variable.
5. Confirm loading, empty, successful, and failed behavior.
### Read-only dashboard
1. Populate a Table through a page-load action.
2. Select a row.
3. Display details in a supported response target where the current Slice 2
model allows it.
4. Confirm refresh and failure behavior.
### Canonical and persistence regression
1. Inspect JSON after all configuration is complete.
2. Confirm there is no new `inputMap` and no `action.responseMapping` behavior.
3. Confirm no credentials or secrets were introduced.
4. Confirm runtime values, loading, responses, and errors are absent.
5. Save and reload the project.
6. Confirm Visual Editor, JSON Editor, Actions & Bindings, and Preview agree.
### Final result
- [ ] Request-input workflow accepted.
- [ ] Response-binding and variable workflow accepted.
- [ ] Page-load workflow accepted.
- [ ] Workflow launcher passed without routine JSON editing.
- [ ] Dependent-data workflow passed.
- [ ] Read-only dashboard workflow passed.
- [ ] Failure handling passed.
- [ ] Canonical synchronization and save/reload passed.
Tester/date:
Notes or defects:
## Recording Results
After a manual workflow is completed:
1. Update its checkboxes in this document.
2. Record the tester, date, environment, and any deviations.
3. Record manual acceptance separately from automated coverage in:
- `CODEX.md`
- `SLICE2.md`
- `BASELINE.md`
4. Do not mark a broader workflow accepted when only one checkpoint passed.
5. File unresolved defects in `TASKS.md` with enough detail to reproduce them.

View File

@ -4,7 +4,7 @@
"project": {
"id": "proj_visual_rest_action_authoring",
"name": "Visual REST Action Authoring",
"description": "Slice 2 fixture covering anonymous visual REST actions, Button onClick assignment, executed request templates, response bindings, and variables.",
"description": "Slice 2 fixture covering anonymous visual REST actions, Button onClick and page onLoad assignments, executed request templates, response bindings, and variables.",
"pages": [
{
"id": "page_main",
@ -48,7 +48,9 @@
"properties": { "label": "Lookup response", "visible": true }
}
],
"events": []
"events": [
{ "event": "onLoad", "actionId": "action_lookup_item" }
]
}
],
"actions": [

View File

@ -263,6 +263,15 @@ describe('Actions & Bindings visual REST action authoring', () => {
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-variable"]'));
setControlValue(container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]'), 'enabled');
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Variable type"]'), 'boolean');
const booleanDefault = container.querySelector<HTMLSelectElement>('select[aria-label="Variable default value"]');
expect(Array.from(booleanDefault?.options ?? []).map((option) => option.value)).toEqual(['', 'true', 'false']);
setControlValue(booleanDefault, 'true');
click(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Save variable') ?? null);
expect(currentDocument(container).project.variables.enabled).toEqual({ type: 'boolean', defaultValue: true });
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' });

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import type { Variable, VariableType } from '../../types/project';
import { formatVariableDefault, parseVariableDefault, validateVariableName } from './configurationUtils';
import styles from './ActionInspector.module.css';
@ -25,15 +25,11 @@ export default function VariableEditor({ name, variable, variables, isNew = fals
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}>Default value</span>{type === 'boolean' ? <select className={styles.formInput} aria-label="Variable default value" value={defaultDraft} onChange={(event) => setDefaultDraft(event.target.value)}><option value="">No default</option><option value="true">true</option><option value="false">false</option></select> : <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

@ -57,6 +57,21 @@
/* ── Page tabs ───────────────────────────────────────────────────── */
.pageLoadStatus {
padding: 7px 18px;
border-bottom: 1px solid #bfdbfe;
background: #eff6ff;
color: #1e40af;
font-size: 12px;
flex-shrink: 0;
}
.pageLoadError {
border-bottom-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.tabs {
display: flex;
gap: 0;

View File

@ -48,6 +48,19 @@ function Preview(): React.ReactElement {
</div>
{/* ── Page tabs (shown only when more than one page exists) ─── */}
{runtime.pageLoadState.status !== 'idle' && (
<div
className={[
styles.pageLoadStatus,
runtime.pageLoadState.status === 'error' ? styles.pageLoadError : '',
].join(' ')}
role={runtime.pageLoadState.status === 'error' ? 'alert' : 'status'}
data-testid="page-load-status"
>
{runtime.pageLoadState.message}
</div>
)}
{pages.length > 1 && (
<div className={styles.tabs}>
{pages.map((page, i) => (

View File

@ -19,6 +19,7 @@
/* ── Button ───────────────────────────────────────────────────────── */
.buttonWrapper {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
@ -61,15 +62,23 @@
}
.buttonError {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 5;
box-sizing: border-box;
min-width: 100%;
width: max-content;
max-width: 320px;
font-size: 11px;
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 4px;
padding: 3px 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
}
/* ── Text input ───────────────────────────────────────────────────── */
@ -286,7 +295,7 @@
font-weight: 600;
font-size: 12px;
text-align: left;
white-space: nowrap;
white-space: normal;
color: #57606a;
position: sticky;
top: 0;

View File

@ -11,7 +11,8 @@ jest.mock('../../api/proxyApi', () => ({ executeAction: jest.fn() }));
const doc: ProjectDocument = {
schemaVersion: '0.1.0',
project: {
id: 'project', name: 'Project', variables: {}, bindings: [],
id: 'project', name: 'Project', variables: {},
bindings: [{ id: 'binding_response_1', source: 'actions.action_a.response.body', target: 'components.requestResult.value', trigger: 'onSuccess' }],
settings: {},
actions: [{ id: 'action_a', name: 'Action A', method: 'GET', url: 'https://example.com', authenticationType: 'anonymous' }],
pages: [{
@ -21,6 +22,9 @@ const doc: ProjectDocument = {
position: { x: 0, y: 0 }, size: { width: 100, height: 40 },
properties: { label: 'Run' },
events: [{ event: 'onClick', actionId: 'action_a' }],
}, {
id: 'viewer', name: 'requestResult', type: 'JsonViewer',
position: { x: 0, y: 60 }, size: { width: 200, height: 100 }, properties: {},
}],
}],
},
@ -53,5 +57,6 @@ describe('Preview Button event dispatch', () => {
await Promise.resolve();
});
expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ id: 'action_a' }));
expect(runtime.componentState.viewer).toMatchObject({ value: { ran: true }, loading: false, error: undefined });
});
});

View File

@ -29,6 +29,7 @@ function render(type: ComponentType, properties: CanvasComponent['properties'],
}
describe('remaining MVP preview components', () => {
test('renders the complete Button action error as an alert', () => { const message = 'Action returned HTTP 503 Service Temporarily Unavailable.'; const markup = render('Button', { label: 'Run' }, { error: message }); expect(markup).toContain('role='); expect(markup).toContain(message); });
test('renders a TextArea with its configured value', () => {
expect(render('TextArea', { label: 'Notes', defaultValue: 'Initial notes' }))
.toContain('Initial notes');

View File

@ -0,0 +1,119 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';
import type { ProjectDocument } from '../../types/project';
import { executeAction } from '../../api/proxyApi';
import { usePreviewRuntime } from './usePreviewRuntime';
jest.mock('../../api/proxyApi', () => ({ executeAction: jest.fn() }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const doc: ProjectDocument = {
schemaVersion: '0.1.0',
project: {
id: 'project',
name: 'Project',
variables: { loadStatus: { type: 'string', defaultValue: 'Not run' } },
bindings: [
{ id: 'binding_component', source: 'actions.action_load.response.body', target: 'components.result.value', trigger: 'onSuccess' },
{ id: 'binding_variable', source: 'actions.action_load.response.body.status', target: 'variables.loadStatus', trigger: 'onSuccess' },
],
settings: {},
actions: [{ id: 'action_load', name: 'Load', method: 'GET', url: 'https://example.com/{{variables.loadStatus}}', authenticationType: 'anonymous' }],
pages: [{
id: 'page',
name: 'Page',
events: [{ event: 'onLoad', actionId: 'action_load' }],
components: [{
id: 'viewer',
name: 'result',
type: 'JsonViewer',
position: { x: 0, y: 0 },
size: { width: 200, height: 100 },
properties: {},
}],
}],
},
};
describe('Preview page onLoad initialization', () => {
let container: HTMLDivElement;
let root: Root;
let runtime: ReturnType<typeof usePreviewRuntime>;
function Harness({ project = doc }: { project?: ProjectDocument }) {
runtime = usePreviewRuntime(project);
return null;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
(executeAction as jest.Mock).mockReset();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test('executes once per Preview initialization and delivers component and variable bindings', async () => {
(executeAction as jest.Mock).mockResolvedValue({ ok: true, status: 200, statusText: 'OK', headers: {}, body: { status: 'Loaded' } });
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
expect(executeAction).toHaveBeenCalledTimes(1);
expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.com/Not run' }));
expect(runtime.componentState.viewer).toMatchObject({ value: { status: 'Loaded' }, loading: false });
expect(runtime.variableState.loadStatus).toBe('Loaded');
expect(runtime.pageLoadState).toMatchObject({ status: 'success', actionId: 'action_load' });
act(() => root.render(<Harness />));
expect(executeAction).toHaveBeenCalledTimes(1);
act(() => root.unmount());
root = createRoot(container);
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
expect(executeAction).toHaveBeenCalledTimes(2);
});
test('reports failed requests without applying bindings or mutating canonical defaults', async () => {
(executeAction as jest.Mock).mockResolvedValue({ ok: false, status: 503, statusText: 'Unavailable', headers: {}, body: null });
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
expect(runtime.pageLoadState).toMatchObject({ status: 'error', actionId: 'action_load' });
expect(runtime.componentState.viewer).toMatchObject({ loading: false });
expect(runtime.componentState.viewer.value).toBeUndefined();
expect(runtime.variableState.loadStatus).toBe('Not run');
expect(doc.project.variables.loadStatus.defaultValue).toBe('Not run');
});
test('reports a missing action without calling the proxy', () => {
const missingDoc: ProjectDocument = {
...doc,
project: {
...doc.project,
pages: [{ ...doc.project.pages[0], events: [{ event: 'onLoad', actionId: 'missing' }] }],
},
};
act(() => root.render(<Harness project={missingDoc} />));
expect(executeAction).not.toHaveBeenCalled();
expect(runtime.pageLoadState).toEqual({
status: 'error',
actionId: 'missing',
message: 'Page onLoad references missing action "missing".',
});
});
});

View File

@ -122,6 +122,12 @@ export type ComponentRuntimeState = {
selectedRow?: Record<string, unknown>;
};
export type PageLoadRuntimeState = {
status: 'idle' | 'loading' | 'success' | 'error';
actionId?: string;
message?: string;
};
export type PreviewRuntime = {
/**
* Map from component ID ephemeral runtime state.
@ -141,6 +147,9 @@ export type PreviewRuntime = {
*/
actionState: ActionRuntimeStateMap;
/** Status of the initial page onLoad action for this Preview initialization. */
pageLoadState: PageLoadRuntimeState;
/**
* Ephemeral runtime variable state keyed by variable name. (Step 18.1)
* Initialized from project.variables.defaultValue on Preview mount.
@ -439,6 +448,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
const [componentState, setComponentState] = useState<Record<string, ComponentRuntimeState>>({});
const [buttonLoading, setButtonLoading] = useState<Record<string, boolean>>({});
const [actionState, setActionState] = useState<ActionRuntimeStateMap>({});
const [pageLoadState, setPageLoadState] = useState<PageLoadRuntimeState>({ status: 'idle' });
// ── Step 18.1: Runtime variable state ─────────────────────────────────────
// Separate from project.variables — never mutates the canonical document.
@ -457,6 +467,120 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
}
}, [doc.project.variables]);
// Execute the initial page's configured onLoad action exactly once for this
// mounted Preview runtime. Re-entering Preview creates a fresh runtime.
useEffect(() => {
const { pages, actions, bindings, variables } = doc.project;
const loadEvent = pages[0]?.events?.find((event) => event.event === 'onLoad');
if (!loadEvent) return;
const actionId = loadEvent.actionId;
const action = actions.find((candidate) => candidate.id === actionId);
if (!action) {
setPageLoadState({
status: 'error',
actionId,
message: `Page onLoad references missing action "${actionId}".`,
});
return;
}
const allComponents = pages.flatMap((page) => page.components);
const componentsByName = new Map<string, string>(
allComponents.map((component) => [component.name, component.id]),
);
const declaredVariableNames = new Set(Object.keys(variables));
const initialVariableState = initializeVariableState(variables);
const responseMappingTargetIds = new Set<string>();
for (const binding of bindings) {
const parsed = parseActionSourcePath(binding.source);
if (!parsed || parsed.actionId !== actionId || classifyTrigger(binding.trigger) === 'unsupported') continue;
const parsedTarget = parseComponentTargetPath(binding.target);
if (!parsedTarget) continue;
const candidates = allComponents.filter((component) => component.name === parsedTarget.componentName);
if (candidates.length === 1 && isTargetPropertySupported(candidates[0].type, parsedTarget.property)) {
responseMappingTargetIds.add(candidates[0].id);
}
}
const clearTargetLoading = () => {
for (const targetId of responseMappingTargetIds) {
setComponentState((prev) => ({
...prev,
[targetId]: { ...prev[targetId], loading: false },
}));
}
};
setPageLoadState({ status: 'loading', actionId, message: `Running "${action.name}"...` });
setActionState((prev) => ({
...prev,
[actionId]: { ...prev[actionId], loading: true, error: undefined },
}));
for (const targetId of responseMappingTargetIds) {
setComponentState((prev) => ({
...prev,
[targetId]: { ...prev[targetId], loading: true, error: undefined },
}));
}
let renderedAction: RestAction;
try {
renderedAction = renderAction(action, componentsByName, {}, initialVariableState, declaredVariableNames);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setActionState((prev) => ({ ...prev, [actionId]: { loading: false, error: message } }));
setPageLoadState({ status: 'error', actionId, message });
clearTargetLoading();
return;
}
executeAction(renderedAction)
.then((proxyResponse) => {
const updatedActionState: ActionRuntimeStateMap = {
[actionId]: { response: proxyResponse, loading: false, error: undefined },
};
setActionState((prev) => ({ ...prev, ...updatedActionState }));
if (!proxyResponse.ok) {
const message = `Action "${action.name}" returned HTTP ${proxyResponse.status} ${proxyResponse.statusText}.`;
setPageLoadState({ status: 'error', actionId, message });
clearTargetLoading();
return;
}
const { componentUpdates, variableUpdates } = applyResponseBindings(
actionId,
proxyResponse,
bindings,
allComponents,
updatedActionState,
{},
declaredVariableNames,
);
if (Object.keys(componentUpdates).length > 0) {
setComponentState((prev) => ({ ...prev, ...componentUpdates }));
}
if (Object.keys(variableUpdates).length > 0) {
setVariableState((prev) => ({ ...prev, ...variableUpdates }));
}
setPageLoadState({
status: 'success',
actionId,
message: `Page load action "${action.name}" completed.`,
});
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
setActionState((prev) => ({ ...prev, [actionId]: { loading: false, error: message } }));
setPageLoadState({ status: 'error', actionId, message });
clearTargetLoading();
});
// Preview initialization intentionally captures the canonical document at mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTextInputChange = useCallback((componentId: string, value: string) => {
setComponentState((prev) => ({
...prev,
@ -550,6 +674,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
// ── 5. Mark loading state ──────────────────────────────────────────
setButtonLoading((prev) => ({ ...prev, [buttonId]: true }));
setComponentState((prev) => ({ ...prev, [buttonId]: { ...prev[buttonId], loading: true, error: undefined } }));
setActionState((prev) => ({
...prev,
[actionId]: { ...prev[actionId], loading: true, error: undefined },
@ -593,6 +718,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
};
setActionState((prev) => ({ ...prev, ...updatedActionState }));
setComponentState((prev) => ({ ...prev, [buttonId]: { ...prev[buttonId], loading: false, error: undefined } }));
// ── 8. Apply response-mapping bindings ─────────────────────
if (proxyResponse.ok) {
@ -829,6 +955,7 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime {
componentState,
buttonLoading,
actionState,
pageLoadState,
variableState,
handleButtonClick,
handleTextInputChange,

View File

@ -0,0 +1,61 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';
import ComponentDeleteDialog from './ComponentDeleteDialog';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe('ComponentDeleteDialog', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test('lists references and exposes explicit cancel and delete-anyway paths', () => {
const onCancel = jest.fn();
const onConfirm = jest.fn();
act(() => root.render(
<ComponentDeleteDialog
pending={{
component: { id: 'table', name: 'postsTable', type: 'Table', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, properties: {} },
references: [{ kind: 'binding', label: 'Binding "binding_response_1" target' }],
}}
onCancel={onCancel}
onConfirm={onConfirm}
/>,
));
expect(container.querySelector('[role="dialog"]')?.textContent).toContain('postsTable');
expect(container.textContent).toContain('Binding "binding_response_1" target');
const buttons = Array.from(container.querySelectorAll('button'));
act(() => buttons.find((button) => button.textContent === 'Cancel')?.click());
act(() => buttons.find((button) => button.textContent === 'Delete anyway')?.click());
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test('cancels on Escape', () => {
const onCancel = jest.fn();
act(() => root.render(
<ComponentDeleteDialog
pending={{
component: { id: 'input', name: 'itemInput', type: 'TextInput', position: { x: 0, y: 0 }, size: { width: 100, height: 40 }, properties: {} },
references: [{ kind: 'actionTemplate', label: 'Action "Load posts" query parameter "item"' }],
}}
onCancel={onCancel}
onConfirm={jest.fn()}
/>,
));
act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
import type { PendingComponentDeletion } from './componentDeletionUtils';
import styles from './VisualEditor.module.css';
type Props = {
pending: PendingComponentDeletion;
onCancel: () => void;
onConfirm: () => void;
};
export default function ComponentDeleteDialog({ pending, onCancel, onConfirm }: Props): React.ReactElement {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onCancel]);
const { component, references } = pending;
return (
<div className={styles.deleteDialogBackdrop}>
<section className={styles.deleteDialog} role="dialog" aria-modal="true" aria-labelledby="component-delete-title">
<h2 id="component-delete-title">Delete referenced component?</h2>
<p>
Component <strong>{component.name}</strong> is referenced by {references.length}{' '}
configuration item{references.length === 1 ? '' : 's'}:
</p>
<ul>{references.map((reference, index) => <li key={`${reference.kind}-${index}`}>{reference.label}</li>)}</ul>
<p>Deleting it will leave broken references. Existing bindings and actions will be preserved for diagnosis.</p>
<div className={styles.deleteDialogActions}>
<button type="button" onClick={onCancel} autoFocus>Cancel</button>
<button type="button" className={styles.dangerButton} onClick={onConfirm}>Delete anyway</button>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,43 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';
import PageEventEditor from './PageEventEditor';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const actions = [
{ id: 'action_a', name: 'Action A', method: 'GET' as const, url: 'https://example.com', authenticationType: 'anonymous' as const },
{ id: 'action_b', name: 'Action B', method: 'POST' as const, url: 'https://example.com', authenticationType: 'anonymous' as const },
];
describe('PageEventEditor', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); });
afterEach(() => { act(() => root.unmount()); container.remove(); });
test('shows an empty state and emits immediate canonical onLoad changes', () => {
const onChange = jest.fn();
act(() => root.render(<PageEventEditor actions={actions} events={[]} onChange={onChange} />));
const select = container.querySelector<HTMLSelectElement>('[data-testid="page-onload-action"]')!;
expect(select.value).toBe('');
expect(select.options[0].text).toBe('No action');
act(() => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(select, 'action_b');
select.dispatchEvent(new Event('change', { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith([{ event: 'onLoad', actionId: 'action_b' }]);
});
test('surfaces a missing action reference and allows clearing it', () => {
const onChange = jest.fn();
act(() => root.render(<PageEventEditor actions={actions} events={[{ event: 'onLoad', actionId: 'missing' }]} onChange={onChange} />));
expect(container.querySelector('[role="alert"]')?.textContent).toContain('Missing action "missing"');
const select = container.querySelector<HTMLSelectElement>('[data-testid="page-onload-action"]')!;
act(() => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(select, '');
select.dispatchEvent(new Event('change', { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith([]);
});
});

View File

@ -0,0 +1,25 @@
import React from 'react';
import type { ComponentEvent, RestAction } from '../../types/project';
import { assignPageOnLoad, getPageOnLoadActionId } from './pageEventUtils';
import styles from './VisualEditor.module.css';
type Props = { events: ComponentEvent[] | undefined; actions: RestAction[]; onChange: (events: ComponentEvent[]) => void };
export default function PageEventEditor({ events, actions, onChange }: Props): React.ReactElement {
const actionId = getPageOnLoadActionId(events);
const missingReference = actionId !== '' && !actions.some((action) => action.id === actionId);
return (
<div className={styles.pageEventEditor} data-testid="page-onload-editor">
<label className={styles.pageEventLabel} htmlFor="page-onload-action">On load</label>
<select id="page-onload-action" data-testid="page-onload-action" className={styles.pageEventSelect}
value={actionId} onChange={(event) => onChange(assignPageOnLoad(events, event.target.value))}
style={missingReference ? { borderColor: '#b91c1c' } : undefined}>
<option value="">No action</option>
{missingReference && <option value={actionId}>Missing action: {actionId}</option>}
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
</select>
{actions.length === 0 && !missingReference && <span className={styles.pageEventHint}>Create a REST action first.</span>}
{missingReference && <span className={styles.pageEventError} role="alert">Missing action &quot;{actionId}&quot;.</span>}
</div>
);
}

View File

@ -55,6 +55,40 @@
margin-left: auto;
}
.pageEventEditor {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.pageEventLabel,
.pageEventHint,
.pageEventError {
font-size: 11px;
white-space: nowrap;
}
.pageEventLabel,
.pageEventHint {
color: #57606a;
}
.pageEventError {
color: #b91c1c;
}
.pageEventSelect {
width: 190px;
min-width: 0;
padding: 2px 6px;
border: 1px solid #d0d7de;
border-radius: 4px;
background: #ffffff;
color: #1f2328;
font-size: 11px;
}
/* ── Body: palette | canvas | info ───────────────────────────────── */
.body {
@ -164,3 +198,58 @@
white-space: pre;
background: #f7f8fa;
}
.deleteDialogBackdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(31, 35, 40, 0.48);
}
.deleteDialog {
width: min(520px, 100%);
max-height: calc(100vh - 48px);
overflow: auto;
padding: 20px;
border: 1px solid #d0d7de;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 36px rgba(31, 35, 40, 0.24);
color: #1f2328;
}
.deleteDialog h2 {
margin: 0 0 12px;
font-size: 18px;
}
.deleteDialog p,
.deleteDialog li {
font-size: 13px;
line-height: 1.5;
}
.deleteDialogActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.deleteDialogActions button {
padding: 6px 12px;
border: 1px solid #d0d7de;
border-radius: 4px;
background: #ffffff;
cursor: pointer;
}
.deleteDialogActions .dangerButton {
border-color: #cf222e;
background: #cf222e;
color: #ffffff;
}

View File

@ -6,6 +6,9 @@ import { useProject } from '../../context/ProjectContext';
import styles from './VisualEditor.module.css';
import type { ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
import ButtonEventEditor from './ButtonEventEditor';
import PageEventEditor from './PageEventEditor';
import ComponentDeleteDialog from './ComponentDeleteDialog';
import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils';
// ── Dropdown options row editor ───────────────────────────────────────────────
@ -292,6 +295,7 @@ function VisualEditor(): React.ReactElement {
updateComponentName,
updateComponentSize,
updateComponentEvents,
updatePageEvents,
} = canvas;
const selectedComponent = activePage.components.find((x) => x.id === selectedId) ?? null;
@ -299,6 +303,7 @@ function VisualEditor(): React.ReactElement {
// Local state for width/height text inputs (allows typing without snapping mid-edit)
const [widthDraft, setWidthDraft] = useState<string>('');
const [heightDraft, setHeightDraft] = useState<string>('');
const [pendingDeletion, setPendingDeletion] = useState<PendingComponentDeletion | null>(null);
// Sync drafts when selection changes
const prevSelectedId = React.useRef<string | null>(null);
@ -367,6 +372,25 @@ function VisualEditor(): React.ReactElement {
[addComponent],
);
const handleRemoveRequest = useCallback((id: string) => {
const component = activePage.components.find((candidate) => candidate.id === id);
if (!component) return;
const references = findComponentReferences(doc, component.name);
if (references.length > 0) {
setPendingDeletion({ component, references });
return;
}
removeComponent(id);
selectComponent(null);
}, [activePage.components, doc, removeComponent, selectComponent]);
const handleConfirmRemoval = useCallback(() => {
if (!pendingDeletion) return;
removeComponent(pendingDeletion.component.id);
selectComponent(null);
setPendingDeletion(null);
}, [pendingDeletion, removeComponent, selectComponent]);
// Show a loading overlay while fetching a project from the backend
if (isLoading) {
return (
@ -386,6 +410,11 @@ function VisualEditor(): React.ReactElement {
<div className={styles.editorBar}>
<span className={styles.projectName}>{doc.project.name}</span>
<span className={styles.pageName}>{activePage.name}</span>
<PageEventEditor
events={activePage.events}
actions={doc.project.actions}
onChange={updatePageEvents}
/>
<span className={styles.componentCount}>
{activePage.components.length} component
{activePage.components.length !== 1 ? 's' : ''}
@ -401,7 +430,7 @@ function VisualEditor(): React.ReactElement {
selectedId={selectedId}
onSelect={selectComponent}
onMove={moveComponent}
onRemove={(id) => { removeComponent(id); selectComponent(null); }}
onRemove={handleRemoveRequest}
onDrop={handleCanvasDrop}
/>
@ -669,6 +698,13 @@ function VisualEditor(): React.ReactElement {
</div>
</aside>
</div>
{pendingDeletion && (
<ComponentDeleteDialog
pending={pendingDeletion}
onCancel={() => setPendingDeletion(null)}
onConfirm={handleConfirmRemoval}
/>
)}
</div>
);
}

View File

@ -0,0 +1,43 @@
import type { ProjectDocument } from '../../types/project';
import { findComponentReferences } from './componentDeletionUtils';
const doc: ProjectDocument = {
schemaVersion: '0.1.0',
project: {
id: 'project', name: 'Project', variables: {}, settings: {},
pages: [{ id: 'page', name: 'Page', components: [] }],
bindings: [
{ id: 'binding_target', source: 'actions.load.response.body', target: 'components.postsTable.rows', trigger: 'onSuccess' },
{ id: 'binding_source', source: 'components.itemInput.value', target: 'variables.query', trigger: 'onChange' },
{ id: 'binding_other', source: 'actions.load.response.body', target: 'components.other.value', trigger: 'onSuccess' },
],
actions: [{
id: 'load', name: 'Load posts', method: 'GET', authenticationType: 'anonymous',
url: 'https://example.com/{{components.itemInput.value}}',
headers: { 'X-Input': '{{components.itemInput.value}}' },
queryParameters: { item: '{{components.itemInput.value}}' },
pathParameters: { legacy: '{{components.itemInput.value}}' },
bodyTemplate: '{"item":"{{components.itemInput.value}}"}',
}],
},
};
describe('component deletion reference discovery', () => {
test('finds binding targets for a Table without matching similar names', () => {
expect(findComponentReferences(doc, 'postsTable')).toEqual([
{ kind: 'binding', label: 'Binding "binding_target" target' },
]);
expect(findComponentReferences(doc, 'posts')).toEqual([]);
});
test('finds binding sources and every request-template location', () => {
expect(findComponentReferences(doc, 'itemInput')).toEqual([
{ kind: 'binding', label: 'Binding "binding_source" source' },
{ kind: 'actionTemplate', label: 'Action "Load posts" URL' },
{ kind: 'actionTemplate', label: 'Action "Load posts" body' },
{ kind: 'actionTemplate', label: 'Action "Load posts" header "X-Input"' },
{ kind: 'actionTemplate', label: 'Action "Load posts" query parameter "item"' },
{ kind: 'actionTemplate', label: 'Action "Load posts" path parameter "legacy"' },
]);
});
});

View File

@ -0,0 +1,50 @@
import type { CanvasComponent, ProjectDocument, RestAction } from '../../types/project';
export type ComponentReference = {
kind: 'binding' | 'actionTemplate';
label: string;
};
function bindingPathReferencesComponent(path: string, componentName: string): boolean {
const root = `components.${componentName}`;
return path === root || path.startsWith(`${root}.`);
}
function actionTemplateEntries(action: RestAction): Array<[string, string | undefined]> {
return [
['URL', action.url],
['body', action.bodyTemplate],
...Object.entries(action.headers ?? {}).map(([key, value]) => [`header "${key}"`, value] as [string, string]),
...Object.entries(action.queryParameters ?? {}).map(([key, value]) => [`query parameter "${key}"`, value] as [string, string]),
...Object.entries(action.pathParameters ?? {}).map(([key, value]) => [`path parameter "${key}"`, value] as [string, string]),
];
}
export function findComponentReferences(doc: ProjectDocument, componentName: string): ComponentReference[] {
const references: ComponentReference[] = [];
const template = `{{components.${componentName}.value}}`;
for (const binding of doc.project.bindings) {
if (bindingPathReferencesComponent(binding.source, componentName)) {
references.push({ kind: 'binding', label: `Binding "${binding.id}" source` });
}
if (bindingPathReferencesComponent(binding.target, componentName)) {
references.push({ kind: 'binding', label: `Binding "${binding.id}" target` });
}
}
for (const action of doc.project.actions) {
for (const [location, value] of actionTemplateEntries(action)) {
if (value?.includes(template)) {
references.push({ kind: 'actionTemplate', label: `Action "${action.name}" ${location}` });
}
}
}
return references;
}
export type PendingComponentDeletion = {
component: CanvasComponent;
references: ComponentReference[];
};

View File

@ -0,0 +1,26 @@
import { assignPageOnLoad, getPageOnLoadActionId } from './pageEventUtils';
describe('Page onLoad canonical synchronization', () => {
test('adds, changes, and clears an onLoad assignment', () => {
expect(assignPageOnLoad(undefined, 'action_a')).toEqual([{ event: 'onLoad', actionId: 'action_a' }]);
expect(assignPageOnLoad([{ event: 'onLoad', actionId: 'action_a' }], 'action_b')).toEqual([{ event: 'onLoad', actionId: 'action_b' }]);
expect(assignPageOnLoad([{ event: 'onLoad', actionId: 'action_a' }], '')).toEqual([]);
});
test('preserves unrelated events while replacing duplicate onLoad entries', () => {
expect(assignPageOnLoad([
{ event: 'onEnter', actionId: 'action_enter' },
{ event: 'onLoad', actionId: 'old_a', inputMap: { inert: 'legacy' } },
{ event: 'onLoad', actionId: 'old_b' },
{ event: 'onLeave', actionId: 'action_leave' },
], 'new_action')).toEqual([
{ event: 'onEnter', actionId: 'action_enter' },
{ event: 'onLoad', actionId: 'new_action' },
{ event: 'onLeave', actionId: 'action_leave' },
]);
});
test('reports no action when onLoad is absent', () => {
expect(getPageOnLoadActionId([{ event: 'onEnter', actionId: 'action_a' }])).toBe('');
});
});

View File

@ -0,0 +1,17 @@
import type { ComponentEvent } from '../../types/project';
export function getPageOnLoadActionId(events: ComponentEvent[] | undefined): string {
return events?.find((event) => event.event === 'onLoad')?.actionId ?? '';
}
export function assignPageOnLoad(events: ComponentEvent[] | undefined, actionId: string): ComponentEvent[] {
const current = events ?? [];
const firstOnLoadIndex = current.findIndex((event) => event.event === 'onLoad');
if (!actionId) return current.filter((event) => event.event !== 'onLoad');
const nextEvent: ComponentEvent = { event: 'onLoad', actionId };
if (firstOnLoadIndex < 0) return [...current, nextEvent];
return current.flatMap((event, index) => {
if (event.event !== 'onLoad') return [event];
return index === firstOnLoadIndex ? [nextEvent] : [];
});
}

View File

@ -66,6 +66,7 @@ export type CanvasActions = {
updateComponentName: (id: string, name: string) => void;
updateComponentSize: (id: string, width: number, height: number) => void;
updateComponentEvents: (id: string, events: ComponentEvent[]) => void;
updatePageEvents: (events: ComponentEvent[]) => void;
};
export function useCanvasActions(
@ -215,6 +216,16 @@ export function useCanvasActions(
[updatePage],
);
const updatePageEvents = useCallback(
(events: ComponentEvent[]) => {
updatePage((p) => ({
...p,
...(events.length > 0 ? { events } : { events: undefined }),
}));
},
[updatePage],
);
return {
activePage,
addComponent,
@ -224,5 +235,6 @@ export function useCanvasActions(
updateComponentName,
updateComponentSize,
updateComponentEvents,
updatePageEvents,
};
}