Initial
This commit is contained in:
commit
cce97392f7
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build output
|
||||
frontend/build/
|
||||
backend/dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# SQLite databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# SQLite database files
|
||||
backend/data/*.db
|
||||
backend/data/*.db-wal
|
||||
backend/data/*.db-shm
|
||||
|
||||
# IBM Bob generated reports
|
||||
.bob/artifacts/
|
||||
398
ARCHITECTURE.md
Normal file
398
ARCHITECTURE.md
Normal file
@ -0,0 +1,398 @@
|
||||
|
||||
# ARCHITECTURE.md
|
||||
|
||||
# Conductor Architecture
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Conductor is a web-based application builder for creating simple user interfaces backed by REST API endpoints.
|
||||
|
||||
The system consists of:
|
||||
|
||||
* React frontend
|
||||
* Backend API service
|
||||
* SQLite database
|
||||
* Canonical JSON project definition
|
||||
* Server-side REST API proxy
|
||||
* External web server or reverse proxy
|
||||
|
||||
The MVP shall function without AI assistance.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architectural Principle
|
||||
|
||||
The canonical source of truth for a Conductor application is a structured JSON project definition.
|
||||
|
||||
All editors and runtime views operate against this project definition.
|
||||
|
||||
This means:
|
||||
|
||||
* The Visual Editor modifies the JSON definition.
|
||||
* The JSON Editor modifies the same JSON definition.
|
||||
* Preview mode renders from the JSON definition.
|
||||
* Saved projects persist the JSON definition.
|
||||
* Future AI features will read and modify the JSON definition.
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended Stack
|
||||
|
||||
## Frontend
|
||||
|
||||
* React
|
||||
* TypeScript
|
||||
* Drag-and-drop canvas library
|
||||
* Monaco Editor or similar JSON editor
|
||||
* JSON schema validation
|
||||
|
||||
## Backend
|
||||
|
||||
* Node.js with Express/Fastify
|
||||
**or**
|
||||
* Python with FastAPI
|
||||
|
||||
Either is acceptable. Pick whichever is easiest for the team to support.
|
||||
|
||||
## Database
|
||||
|
||||
* SQLite for MVP
|
||||
* PostgreSQL as a future migration target
|
||||
|
||||
## Web Server
|
||||
|
||||
Production deployments should run behind:
|
||||
|
||||
* NGINX
|
||||
* Apache HTTP Server
|
||||
* Caddy
|
||||
* IBM-approved internal reverse proxy
|
||||
|
||||
The application should not implement its own production-grade web server.
|
||||
|
||||
---
|
||||
|
||||
## 4. High-Level Architecture
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
|-- Visual Editor
|
||||
|-- JSON Editor
|
||||
|-- Preview Runtime
|
||||
|
|
||||
v
|
||||
Backend API Service
|
||||
|
|
||||
|-- Project API
|
||||
|-- Validation API
|
||||
|-- REST Proxy API
|
||||
|-- Secret Handling
|
||||
|
|
||||
v
|
||||
SQLite Database
|
||||
|
|
||||
v
|
||||
Stored Project Definitions
|
||||
```
|
||||
|
||||
External API calls should flow through the backend proxy:
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
v
|
||||
Conductor Backend
|
||||
|
|
||||
v
|
||||
External REST API / Concert / RIA Endpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Major Components
|
||||
|
||||
## 5.1 Visual Editor
|
||||
|
||||
The Visual Editor provides the drag-and-drop interface.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Render canvas from project JSON
|
||||
* Add components
|
||||
* Move components
|
||||
* Resize components
|
||||
* Edit properties
|
||||
* Configure events
|
||||
* Configure bindings
|
||||
* Update canonical project JSON
|
||||
|
||||
## 5.2 JSON Editor
|
||||
|
||||
The JSON Editor provides direct access to the canonical project definition.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Display project JSON
|
||||
* Validate against schema
|
||||
* Show syntax errors
|
||||
* Apply edits to project state
|
||||
* Keep Visual Editor synchronized
|
||||
|
||||
## 5.3 Preview Runtime
|
||||
|
||||
Preview mode renders the project as an end user would experience it.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Render components from JSON
|
||||
* Execute configured events
|
||||
* Call backend proxy for REST actions
|
||||
* Apply response mappings
|
||||
* Display success/error states
|
||||
|
||||
## 5.4 Backend API Service
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Save projects
|
||||
* Load projects
|
||||
* Validate project definitions
|
||||
* Execute REST proxy requests
|
||||
* Store non-secret metadata
|
||||
* Manage secret references
|
||||
* Provide basic execution logs
|
||||
|
||||
## 5.5 REST API Proxy
|
||||
|
||||
The backend shall proxy external REST API calls.
|
||||
|
||||
Reasons:
|
||||
|
||||
* Avoid exposing secrets in the browser
|
||||
* Centralize authentication handling
|
||||
* Support API keys and bearer tokens safely
|
||||
* Normalize errors
|
||||
* Capture sanitized execution logs
|
||||
* Prepare for future endpoint allowlisting
|
||||
|
||||
## 5.6 Database Layer
|
||||
|
||||
SQLite stores:
|
||||
|
||||
* Project metadata
|
||||
* Canonical project JSON
|
||||
* Secret references
|
||||
* Basic execution history
|
||||
* Application settings
|
||||
|
||||
The backend should use a data access layer so SQLite can later be replaced with PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Flow
|
||||
|
||||
## 6.1 Editing Flow
|
||||
|
||||
```text
|
||||
User edits Visual Editor
|
||||
-> Project JSON updated
|
||||
-> Schema validation runs
|
||||
-> UI rerenders
|
||||
-> User saves project
|
||||
-> Backend persists JSON to SQLite
|
||||
```
|
||||
|
||||
## 6.2 JSON Editing Flow
|
||||
|
||||
```text
|
||||
User edits JSON
|
||||
-> JSON parsed
|
||||
-> Schema validation runs
|
||||
-> If valid, project state updates
|
||||
-> Visual Editor rerenders
|
||||
-> User saves project
|
||||
-> Backend persists JSON to SQLite
|
||||
```
|
||||
|
||||
## 6.3 API Execution Flow
|
||||
|
||||
```text
|
||||
User clicks button
|
||||
-> Event fires
|
||||
-> Binding resolves input values
|
||||
-> Backend REST proxy is called
|
||||
-> Backend calls external API
|
||||
-> Response returns to Preview Runtime
|
||||
-> Response mapping updates target component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Project Definition
|
||||
|
||||
The project definition should include:
|
||||
|
||||
* Project metadata
|
||||
* Pages
|
||||
* Components
|
||||
* Layout
|
||||
* Component properties
|
||||
* Events
|
||||
* Actions
|
||||
* Bindings
|
||||
* Variables
|
||||
* Settings
|
||||
* Schema version
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "example-project",
|
||||
"name": "Example Project",
|
||||
"pages": [],
|
||||
"actions": [],
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Model
|
||||
|
||||
The MVP security model should assume:
|
||||
|
||||
* Secrets must not be exposed to the frontend.
|
||||
* API calls requiring secrets must be executed by the backend.
|
||||
* Logs must mask sensitive values.
|
||||
* Project exports should exclude secrets by default.
|
||||
* Anonymous endpoints may be called without stored credentials.
|
||||
* Future deployments may require endpoint allowlisting.
|
||||
|
||||
Potential sensitive values:
|
||||
|
||||
* Authorization headers
|
||||
* API keys
|
||||
* Bearer tokens
|
||||
* Basic auth passwords
|
||||
* Session cookies
|
||||
|
||||
---
|
||||
|
||||
## 9. Deployment Model
|
||||
|
||||
The MVP deployment model should support:
|
||||
|
||||
```text
|
||||
Reverse Proxy
|
||||
-> Frontend static assets
|
||||
-> Backend API service
|
||||
-> SQLite database file
|
||||
```
|
||||
|
||||
A simple Docker Compose deployment is recommended for local demos and early internal use.
|
||||
|
||||
Future deployment options may include:
|
||||
|
||||
* Kubernetes
|
||||
* OpenShift
|
||||
* IBM Cloud Code Engine
|
||||
* Internal IBM hosting platform
|
||||
|
||||
---
|
||||
|
||||
## 10. Suggested Repository Structure
|
||||
|
||||
```text
|
||||
conductor/
|
||||
frontend/
|
||||
src/
|
||||
backend/
|
||||
src/
|
||||
docs/
|
||||
REQUIREMENTS.md
|
||||
NICE-TO-HAVE.md
|
||||
ARCHITECTURE.md
|
||||
examples/
|
||||
project-definitions/
|
||||
docker-compose.yml
|
||||
README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Initial API Surface
|
||||
|
||||
Potential backend endpoints:
|
||||
|
||||
```text
|
||||
GET /api/projects
|
||||
POST /api/projects
|
||||
GET /api/projects/:id
|
||||
PUT /api/projects/:id
|
||||
DELETE /api/projects/:id
|
||||
|
||||
POST /api/projects/:id/validate
|
||||
POST /api/proxy/execute
|
||||
GET /api/projects/:id/executions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Architectural Decisions
|
||||
|
||||
Initial decisions:
|
||||
|
||||
* Conductor is a web application.
|
||||
* Conductor uses a React frontend.
|
||||
* Conductor uses a backend API service.
|
||||
* Conductor uses SQLite for MVP persistence.
|
||||
* Conductor stores projects as canonical JSON documents.
|
||||
* REST API calls flow through the backend proxy.
|
||||
* Secrets are never intentionally exposed to the browser.
|
||||
* Production deployments use an external reverse proxy.
|
||||
* AI assistance is not required for MVP.
|
||||
|
||||
|
||||
|
||||
## Backend-Agnostic REST Integration
|
||||
|
||||
Conductor shall be designed as a backend-agnostic REST UI builder.
|
||||
|
||||
Although the initial target use case is IBM Concert Workflows / Rapid Infrastructure Automation, Conductor should not be tightly coupled to any single automation platform.
|
||||
|
||||
Any system that exposes reachable HTTP/REST endpoints may be used as an integration target.
|
||||
|
||||
Potential integration targets include:
|
||||
|
||||
* IBM Concert Workflows / Rapid Infrastructure Automation
|
||||
* Node-RED HTTP endpoints
|
||||
* Custom internal APIs
|
||||
* FastAPI, Flask, Express, or similar backend services
|
||||
* Other workflow or automation platforms with REST APIs
|
||||
|
||||
Conductor should treat external systems as REST action providers.
|
||||
|
||||
For the MVP, Conductor is responsible for:
|
||||
|
||||
* Rendering the user interface
|
||||
* Collecting user input
|
||||
* Calling configured REST endpoints through the backend proxy
|
||||
* Passing request parameters
|
||||
* Receiving responses
|
||||
* Mapping responses back into UI components
|
||||
|
||||
External systems are responsible for:
|
||||
|
||||
* Workflow execution
|
||||
* Automation logic
|
||||
* Business logic
|
||||
* External integrations
|
||||
* Long-running task handling
|
||||
|
||||
Conductor should avoid implementing workflow orchestration internally unless required by a future enhancement.
|
||||
8
Conductor.code-workspace
Normal file
8
Conductor.code-workspace
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
155
NICE-TO-HAVE.md
Normal file
155
NICE-TO-HAVE.md
Normal file
@ -0,0 +1,155 @@
|
||||
# NICE-TO-HAVE.md
|
||||
|
||||
# Future Enhancements for Conductor
|
||||
|
||||
The following features are considered desirable enhancements but are not required for the initial MVP.
|
||||
|
||||
---
|
||||
|
||||
# AI-Assisted Development
|
||||
|
||||
Conductor should be designed so that AI capabilities can be integrated without changing the underlying project architecture.
|
||||
|
||||
AI assistance should operate by reading and modifying the project's canonical JSON definition.
|
||||
|
||||
The application shall remain fully functional when AI services are unavailable.
|
||||
|
||||
---
|
||||
|
||||
## AI Chat
|
||||
|
||||
Provide an integrated conversational interface for interacting with a project.
|
||||
|
||||
Example requests:
|
||||
|
||||
* Build a login page.
|
||||
* Add a table below the dropdown.
|
||||
* Connect this button to a REST endpoint.
|
||||
* Explain what this page does.
|
||||
* Rename all references to "Environment" as "Target Environment."
|
||||
* Improve the layout.
|
||||
* Add validation to required fields.
|
||||
* Document this application.
|
||||
|
||||
The AI should generate proposed project changes rather than modifying the project without user approval.
|
||||
|
||||
---
|
||||
|
||||
## AI Project Generation
|
||||
|
||||
Allow users to create an application from a natural language description.
|
||||
|
||||
Example:
|
||||
|
||||
> Build a form that accepts a hostname, environment, and owner, then calls a Rapid Infrastructure Automation workflow and displays the results.
|
||||
|
||||
The AI should generate:
|
||||
|
||||
* Pages
|
||||
* Components
|
||||
* Layout
|
||||
* REST actions
|
||||
* Bindings
|
||||
* Default styling
|
||||
|
||||
The generated project should immediately open in the Visual Editor for refinement.
|
||||
|
||||
---
|
||||
|
||||
## AI-Assisted REST Configuration
|
||||
|
||||
The AI may assist users by:
|
||||
|
||||
* Creating REST action definitions
|
||||
* Suggesting request bodies
|
||||
* Generating headers
|
||||
* Creating authentication configurations
|
||||
* Mapping API responses to UI components
|
||||
* Suggesting validation rules
|
||||
|
||||
---
|
||||
|
||||
## AI Documentation
|
||||
|
||||
Generate documentation from an existing project.
|
||||
|
||||
Potential outputs include:
|
||||
|
||||
* Markdown documentation
|
||||
* API documentation
|
||||
* End-user documentation
|
||||
* Administrator documentation
|
||||
* Project summaries
|
||||
|
||||
---
|
||||
|
||||
## AI Refactoring
|
||||
|
||||
Allow the AI to improve an existing project.
|
||||
|
||||
Examples include:
|
||||
|
||||
* Simplifying layouts
|
||||
* Removing unused components
|
||||
* Consolidating duplicate REST actions
|
||||
* Improving naming consistency
|
||||
* Reorganizing pages
|
||||
* Suggesting accessibility improvements
|
||||
|
||||
---
|
||||
|
||||
## AI Validation
|
||||
|
||||
The AI may analyze projects for potential issues, including:
|
||||
|
||||
* Missing bindings
|
||||
* Invalid REST configurations
|
||||
* Unused components
|
||||
* Circular dependencies
|
||||
* Missing required inputs
|
||||
* Security concerns
|
||||
* Inconsistent naming
|
||||
|
||||
---
|
||||
|
||||
## AI Explainability
|
||||
|
||||
Allow users to ask questions about an existing project.
|
||||
|
||||
Examples include:
|
||||
|
||||
* What happens when this button is clicked?
|
||||
* Which components call REST APIs?
|
||||
* Which workflow launches this action?
|
||||
* Where is this value used?
|
||||
* Why is this field disabled?
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI Integration
|
||||
|
||||
Allow users to import an OpenAPI specification and automatically generate:
|
||||
|
||||
* REST action definitions
|
||||
* Forms
|
||||
* CRUD pages
|
||||
* Tables
|
||||
* Documentation
|
||||
|
||||
---
|
||||
|
||||
## Additional Future Enhancements
|
||||
|
||||
* Multi-page applications
|
||||
* Reusable component libraries
|
||||
* Themes and styling templates
|
||||
* Workflow templates
|
||||
* Project version history
|
||||
* Git integration
|
||||
* Team collaboration
|
||||
* Role-based access control
|
||||
* Plugin architecture
|
||||
* Additional authentication providers
|
||||
* Internationalization
|
||||
* Accessibility auditing
|
||||
* Application packaging and deployment
|
||||
98
README.md
Normal file
98
README.md
Normal file
@ -0,0 +1,98 @@
|
||||
# Conductor
|
||||
|
||||
A web-based, drag-and-drop UI builder for creating simple frontend applications backed by REST API endpoints.
|
||||
|
||||
Designed for IBM Concert Workflows / Rapid Infrastructure Automation, but backend-agnostic — any system that exposes HTTP/REST endpoints can be used as an integration target.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
- **Visual Editor** — drag-and-drop canvas for placing and configuring UI components
|
||||
- **JSON Editor** — direct access to the canonical project definition
|
||||
- **Preview Mode** — run the application as an end user
|
||||
- **REST Proxy** — server-side proxy for API calls; secrets never reach the browser
|
||||
- **AI Assistance** — IBM Bob / watsonx can generate and refine project configurations
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
conductor/
|
||||
├── frontend/ # React + TypeScript UI
|
||||
│ ├── public/
|
||||
│ └── src/
|
||||
├── backend/ # Node.js + Express + TypeScript API
|
||||
│ └── src/
|
||||
├── docs/ # Project documentation
|
||||
│ ├── REQUIREMENTS.md
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── NICE-TO-HAVE.md
|
||||
│ └── BUILD_AND_TEST_PLAN.md
|
||||
├── examples/
|
||||
│ └── project-definitions/
|
||||
├── docker-compose.yml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm start
|
||||
# Runs on http://localhost:3000
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
# Runs on http://localhost:4000
|
||||
```
|
||||
|
||||
The frontend development server proxies `/api/*` requests to `http://localhost:4000`.
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
# Frontend: http://localhost:3000
|
||||
# Backend: http://localhost:4000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|---|---|
|
||||
| [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 |
|
||||
| [docs/BUILD_AND_TEST_PLAN.md](docs/BUILD_AND_TEST_PLAN.md) | Incremental build and test plan |
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Frontend | React 18, TypeScript 5 |
|
||||
| Backend | Node.js 20, Express 4, TypeScript 5 |
|
||||
| Database | SQLite (MVP) |
|
||||
| Deployment | Docker Compose, NGINX/reverse proxy |
|
||||
692
REQUIREMENTS.md
Normal file
692
REQUIREMENTS.md
Normal file
@ -0,0 +1,692 @@
|
||||
# REQUIREMENTS.md
|
||||
|
||||
# Project: AI-Assisted REST UI Builder
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Build a lightweight UI builder that allows users to create simple frontend applications backed by REST API endpoints, with a focus on IBM Concert Workflows / Rapid Infrastructure Automation use cases.
|
||||
|
||||
The tool should allow users to drag and drop UI elements onto a canvas, configure those elements, connect them to REST API calls, and allow one UI element to update another based on API responses or user interaction.
|
||||
|
||||
IBM Bob / watsonx should assist with generating, configuring, documenting, and refining these UI applications.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
The system should:
|
||||
|
||||
* Allow users to visually build simple UI workflows.
|
||||
* Support drag-and-drop placement of UI components on a canvas.
|
||||
* Allow UI components to call REST API endpoints.
|
||||
* Support multiple authentication types, including anonymous access.
|
||||
* Allow API responses to update other UI components.
|
||||
* Make it easier for CSMs, architects, and technical users to build working demos or operational tools without hand-coding every frontend.
|
||||
* Use AI assistance to speed up configuration, explanation, and generation of UI/API bindings.
|
||||
|
||||
---
|
||||
|
||||
## 3. Non-Goals
|
||||
|
||||
The system is not intended to be a full Appsmith replacement.
|
||||
|
||||
The MVP will not include:
|
||||
|
||||
* Full database integrations.
|
||||
* Complex multi-user application publishing.
|
||||
* Advanced permissions or RBAC.
|
||||
* Marketplace-style widget plugins.
|
||||
* Pixel-perfect design tooling.
|
||||
* Full workflow orchestration.
|
||||
* Public SaaS hosting.
|
||||
* Complex version control or branching.
|
||||
* Enterprise-grade audit logging.
|
||||
|
||||
---
|
||||
|
||||
## 4. Primary Users
|
||||
|
||||
### 4.1 Customer Success Managers
|
||||
|
||||
CSMs may use the tool to build demos, internal utilities, workflow launchers, and simple operational dashboards.
|
||||
|
||||
### 4.2 Technical Sellers / Architects
|
||||
|
||||
Technical users may use the tool to demonstrate how REST APIs, Concert Workflows, or Rapid Infrastructure Automation endpoints can be exposed through a simple UI.
|
||||
|
||||
### 4.3 Internal Operators
|
||||
|
||||
Internal IBM teams may use the tool to create small utilities for repeatable operational tasks.
|
||||
|
||||
---
|
||||
|
||||
## 5. Core Use Cases
|
||||
|
||||
### 5.1 Build a Simple Workflow Launcher
|
||||
|
||||
A user creates a page with:
|
||||
|
||||
* A dropdown to select an environment.
|
||||
* A text input for parameters.
|
||||
* A button to call a REST endpoint.
|
||||
* A status/output panel showing the response.
|
||||
|
||||
### 5.2 Build a Dependent Dropdown UI
|
||||
|
||||
A user creates:
|
||||
|
||||
* A dropdown that calls an API to retrieve available environments.
|
||||
* A second dropdown that updates based on the selected environment.
|
||||
* A button that launches a workflow using both selected values.
|
||||
|
||||
### 5.3 Build a Read-Only API Dashboard
|
||||
|
||||
A user creates:
|
||||
|
||||
* A table connected to a REST endpoint.
|
||||
* A refresh button.
|
||||
* A details panel that updates when a table row is selected.
|
||||
|
||||
### 5.4 Build a Concert/RIA Workflow Form
|
||||
|
||||
A user creates:
|
||||
|
||||
* Input fields for required workflow parameters.
|
||||
* Authentication configuration.
|
||||
* A submit button that calls the workflow endpoint.
|
||||
* A response display showing success, failure, or returned data.
|
||||
|
||||
---
|
||||
|
||||
## 6. Functional Requirements
|
||||
|
||||
## 6.1 Canvas Builder
|
||||
|
||||
The system shall provide a visual canvas where users can place UI elements.
|
||||
|
||||
The canvas shall support:
|
||||
|
||||
* Drag-and-drop widget placement.
|
||||
* Selecting widgets.
|
||||
* Moving widgets.
|
||||
* Resizing widgets.
|
||||
* Deleting widgets.
|
||||
* Basic alignment or snapping.
|
||||
* Saving the canvas layout.
|
||||
|
||||
## 6.2 UI Components
|
||||
|
||||
The MVP shall support the following UI components:
|
||||
|
||||
* Button
|
||||
* Text input
|
||||
* Text area
|
||||
* Dropdown/select
|
||||
* Checkbox
|
||||
* Radio group
|
||||
* Static text/label
|
||||
* Table
|
||||
* JSON response viewer
|
||||
* Status/message panel
|
||||
* Container/card
|
||||
|
||||
Future components may include:
|
||||
|
||||
* Tabs
|
||||
* Modal dialog
|
||||
* Date picker
|
||||
* File upload
|
||||
* Chart
|
||||
* Progress indicator
|
||||
|
||||
## 6.3 Component Configuration
|
||||
|
||||
Each component shall have configurable properties.
|
||||
|
||||
Common properties should include:
|
||||
|
||||
* Component name / ID
|
||||
* Label
|
||||
* Placeholder text
|
||||
* Default value
|
||||
* Visibility
|
||||
* Disabled state
|
||||
* Required state
|
||||
* Styling basics
|
||||
|
||||
Component-specific properties may include:
|
||||
|
||||
* Dropdown options
|
||||
* Table columns
|
||||
* Button action
|
||||
* API binding
|
||||
* Response mapping
|
||||
* Validation rules
|
||||
|
||||
## 6.4 REST API Configuration
|
||||
|
||||
The system shall allow users to define REST API actions.
|
||||
|
||||
Each REST API action shall support:
|
||||
|
||||
* Name
|
||||
* Description
|
||||
* HTTP method: GET, POST, PUT, PATCH, DELETE
|
||||
* Endpoint URL
|
||||
* Headers
|
||||
* Query parameters
|
||||
* Path parameters
|
||||
* Request body
|
||||
* Authentication type
|
||||
* Timeout setting
|
||||
* Expected response format
|
||||
|
||||
The system should support JSON request and response bodies in the MVP.
|
||||
|
||||
## 6.5 Authentication Support
|
||||
|
||||
The system shall support multiple authentication types for REST API calls.
|
||||
|
||||
MVP authentication types:
|
||||
|
||||
* Anonymous / no authentication
|
||||
* Basic authentication
|
||||
* Bearer token
|
||||
* API key in header
|
||||
* API key in query parameter
|
||||
|
||||
Future authentication types may include:
|
||||
|
||||
* OAuth 2.0
|
||||
* mTLS
|
||||
* IBM Cloud IAM token flow
|
||||
* Custom authentication script or pre-request action
|
||||
|
||||
## 6.6 UI-to-API Binding
|
||||
|
||||
The system shall allow a UI component to trigger a REST API action.
|
||||
|
||||
Examples:
|
||||
|
||||
* Button click calls an API endpoint.
|
||||
* Dropdown selection calls an API endpoint.
|
||||
* Page load calls an API endpoint.
|
||||
* Table row selection updates another component.
|
||||
|
||||
The system shall allow component values to be used in API requests.
|
||||
|
||||
Examples:
|
||||
|
||||
* Text input value used in a POST body.
|
||||
* Dropdown value used as a query parameter.
|
||||
* Table row value used in a path parameter.
|
||||
|
||||
## 6.7 Component-to-Component Updates
|
||||
|
||||
The system shall allow one component to update another component.
|
||||
|
||||
Examples:
|
||||
|
||||
* Dropdown A updates the options in Dropdown B.
|
||||
* Button response updates a JSON viewer.
|
||||
* Table selection updates a details panel.
|
||||
* API response updates a status/message component.
|
||||
|
||||
The MVP should support simple response mapping using JSON paths.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "apiResponse.environments",
|
||||
"target": "environmentDropdown.options"
|
||||
}
|
||||
```
|
||||
|
||||
## 6.8 AI Assistance
|
||||
|
||||
IBM Bob / watsonx should assist users by:
|
||||
|
||||
* Suggesting UI layouts from a natural language prompt.
|
||||
* Generating REST API action definitions.
|
||||
* Explaining API responses.
|
||||
* Suggesting response mappings.
|
||||
* Helping generate sample request bodies.
|
||||
* Helping troubleshoot API errors.
|
||||
* Producing documentation for a generated UI.
|
||||
* Suggesting improvements to usability.
|
||||
|
||||
Example prompt:
|
||||
|
||||
> Build a form that lets me select an environment, enter a hostname, and call a Rapid Infrastructure Automation workflow to provision monitoring.
|
||||
|
||||
The AI assistant should produce a proposed page layout, components, API bindings, and configuration steps.
|
||||
|
||||
## 6.9 Save and Load
|
||||
|
||||
The system shall allow users to save a project.
|
||||
|
||||
A saved project should include:
|
||||
|
||||
* Pages
|
||||
* Canvas layout
|
||||
* Components
|
||||
* API action definitions
|
||||
* Component bindings
|
||||
* Response mappings
|
||||
* Non-secret configuration
|
||||
|
||||
Secrets should not be stored directly in exported project files unless encrypted or intentionally allowed by policy.
|
||||
|
||||
## 6.10 Preview Mode
|
||||
|
||||
The system shall provide a preview mode.
|
||||
|
||||
Preview mode shall allow users to:
|
||||
|
||||
* Interact with the UI as an end user.
|
||||
* Trigger API calls.
|
||||
* View API responses.
|
||||
* Validate bindings.
|
||||
* Test component-to-component updates.
|
||||
|
||||
## 6.11 Export
|
||||
|
||||
The system should support exporting the project definition as JSON.
|
||||
|
||||
Future export options may include:
|
||||
|
||||
* React application export
|
||||
* Static frontend bundle
|
||||
* Documentation export
|
||||
* Markdown summary
|
||||
* Deployment package
|
||||
|
||||
## 6.12 Project Editors
|
||||
|
||||
Conductor shall support multiple methods for creating and editing a project.
|
||||
|
||||
### Visual Editor
|
||||
|
||||
The Visual Editor shall be the primary user interface for creating applications.
|
||||
|
||||
The Visual Editor shall provide:
|
||||
|
||||
* Drag-and-drop placement of UI components
|
||||
* Component selection
|
||||
* Component movement and resizing
|
||||
* Property editing
|
||||
* Event configuration
|
||||
* Component binding configuration
|
||||
* Visual indication of selected components
|
||||
* Canvas zoom and pan
|
||||
* Undo and redo operations (future enhancement)
|
||||
|
||||
Changes made through the Visual Editor shall immediately update the project's canonical JSON definition.
|
||||
|
||||
### JSON Editor
|
||||
|
||||
Conductor shall provide a JSON Editor for advanced users.
|
||||
|
||||
The JSON Editor shall allow users to directly edit the project's canonical JSON document.
|
||||
|
||||
The JSON Editor should provide:
|
||||
|
||||
* Syntax highlighting
|
||||
* Automatic formatting
|
||||
* Schema validation
|
||||
* Error reporting
|
||||
* Search and replace
|
||||
* Read-only and editable modes
|
||||
|
||||
Changes made through the JSON Editor shall immediately update the Visual Editor.
|
||||
|
||||
The Visual Editor and JSON Editor shall always represent the same project state.
|
||||
|
||||
### Canonical Project Model
|
||||
|
||||
Both editors shall operate on the same underlying project definition.
|
||||
|
||||
Neither editor shall maintain an independent representation of the application.
|
||||
|
||||
All modifications shall update the project's canonical JSON document, which serves as the authoritative representation of the application.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model
|
||||
|
||||
## 7.1 Project
|
||||
|
||||
A project represents a UI application.
|
||||
|
||||
A project contains:
|
||||
|
||||
* Project ID
|
||||
* Name
|
||||
* Description
|
||||
* Pages
|
||||
* API actions
|
||||
* Global variables
|
||||
* Metadata
|
||||
|
||||
## 7.2 Page
|
||||
|
||||
A page contains:
|
||||
|
||||
* Page ID
|
||||
* Name
|
||||
* Layout
|
||||
* Components
|
||||
* Page-level events
|
||||
|
||||
## 7.3 Component
|
||||
|
||||
A component contains:
|
||||
|
||||
* Component ID
|
||||
* Type
|
||||
* Name
|
||||
* Position
|
||||
* Size
|
||||
* Properties
|
||||
* Events
|
||||
* Bindings
|
||||
|
||||
## 7.4 API Action
|
||||
|
||||
An API action contains:
|
||||
|
||||
* Action ID
|
||||
* Name
|
||||
* Method
|
||||
* URL
|
||||
* Headers
|
||||
* Parameters
|
||||
* Body template
|
||||
* Authentication configuration
|
||||
* Response mapping
|
||||
|
||||
## 7.5 Binding
|
||||
|
||||
A binding defines how data moves between components and actions.
|
||||
|
||||
A binding contains:
|
||||
|
||||
* Source
|
||||
* Target
|
||||
* Trigger event
|
||||
* Transformation rule
|
||||
* Error handling behavior
|
||||
|
||||
## 7.6 Project Definition Document
|
||||
|
||||
The canonical representation of a Conductor project shall be a single structured JSON document.
|
||||
|
||||
This document represents the complete application definition and serves as the authoritative source of truth for the project.
|
||||
|
||||
The project definition shall include:
|
||||
|
||||
* Project metadata
|
||||
* Pages
|
||||
* Canvas layout
|
||||
* UI components
|
||||
* Component properties
|
||||
* Events
|
||||
* REST API action definitions
|
||||
* Component bindings
|
||||
* Global variables
|
||||
* Application settings
|
||||
* Theme information (future)
|
||||
* Version information
|
||||
|
||||
The backend shall persist this project definition as JSON.
|
||||
|
||||
The backend may extract selected metadata into relational database fields for indexing, searching, reporting, or performance optimization, but the JSON project definition remains the canonical representation.
|
||||
|
||||
A project definition should be portable between Conductor installations and suitable for export, import, version control, and AI-assisted modification.
|
||||
|
||||
Example capabilities enabled by this approach include:
|
||||
|
||||
* Exporting a complete application as a single file.
|
||||
* Importing existing applications.
|
||||
* Versioning projects in Git.
|
||||
* Comparing changes between revisions.
|
||||
* AI-assisted editing of complete applications.
|
||||
* Generating documentation from the project definition.
|
||||
* Generating frontend code from the project definition.
|
||||
|
||||
Future versions of Conductor may define a published JSON schema describing the project definition format to support validation, tooling, and interoperability.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 8. Example MVP Workflow
|
||||
|
||||
1. User creates a new project.
|
||||
2. User drags a dropdown onto the canvas.
|
||||
3. User configures the dropdown to call an API endpoint on page load.
|
||||
4. API response populates the dropdown options.
|
||||
5. User drags a text input onto the canvas.
|
||||
6. User drags a button onto the canvas.
|
||||
7. User configures the button to call a POST endpoint.
|
||||
8. The POST body uses values from the dropdown and text input.
|
||||
9. User drags a JSON viewer onto the canvas.
|
||||
10. Button response updates the JSON viewer.
|
||||
11. User tests the flow in preview mode.
|
||||
12. User saves the project.
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Requirements
|
||||
|
||||
## 9.1 Frontend
|
||||
|
||||
Recommended frontend stack:
|
||||
|
||||
* React
|
||||
* TypeScript
|
||||
* Drag-and-drop library
|
||||
* Component state management
|
||||
* JSON schema-driven configuration panels
|
||||
|
||||
## 9.2 Backend
|
||||
|
||||
Recommended backend capabilities:
|
||||
|
||||
* REST API proxy
|
||||
* Project persistence
|
||||
* Secret handling
|
||||
* Authentication configuration storage
|
||||
* API execution logging for troubleshooting
|
||||
* AI assistant integration
|
||||
|
||||
## 9.3 Security
|
||||
|
||||
The system must:
|
||||
|
||||
* Avoid exposing secrets in the frontend.
|
||||
* Store secrets securely.
|
||||
* Mask sensitive values in logs.
|
||||
* Support anonymous API calls where appropriate.
|
||||
* Prevent arbitrary unsafe code execution in user-defined mappings.
|
||||
* Validate URLs and headers before execution.
|
||||
* Consider allowlists for internal endpoint access.
|
||||
|
||||
## 9.4 Observability
|
||||
|
||||
The system should provide basic troubleshooting details:
|
||||
|
||||
* API request timestamp
|
||||
* Method and endpoint
|
||||
* Response status
|
||||
* Response duration
|
||||
* Error message
|
||||
* Sanitized request/response preview
|
||||
|
||||
---
|
||||
|
||||
## 10. MVP Scope
|
||||
|
||||
The MVP should include:
|
||||
|
||||
* Single-page project builder.
|
||||
* Basic canvas.
|
||||
* Core widgets.
|
||||
* REST action configuration.
|
||||
* Anonymous, Basic, Bearer, and API key authentication.
|
||||
* Component-to-API binding.
|
||||
* API response-to-component binding.
|
||||
* Preview mode.
|
||||
* Save/load project as JSON.
|
||||
* IBM Bob/watsonx-assisted generation of project configuration.
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Enhancements
|
||||
|
||||
Potential future enhancements:
|
||||
|
||||
* Multi-page applications.
|
||||
* Role-based access control.
|
||||
* OAuth support.
|
||||
* IBM Cloud IAM integration.
|
||||
* Workflow execution history.
|
||||
* Generated React code export.
|
||||
* Import from OpenAPI specification.
|
||||
* Visual JSON path mapper.
|
||||
* Charts and dashboards.
|
||||
* Reusable templates.
|
||||
* Version history.
|
||||
* Approval workflows.
|
||||
* Deployment to internal hosting.
|
||||
* Git integration.
|
||||
* Team collaboration.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions
|
||||
|
||||
* Should this be a standalone internal tool or embedded into another IBM workflow?
|
||||
* Will API calls execute directly from the browser or through a backend proxy?
|
||||
* How should secrets be stored and managed?
|
||||
* Which Concert/RIA authentication methods are required first?
|
||||
* Should OpenAPI import be part of MVP or future scope?
|
||||
* Is the primary output a working hosted UI, a generated React app, or a reusable project definition?
|
||||
* What level of audit logging is required for internal IBM use?
|
||||
* Should users be allowed to call arbitrary URLs?
|
||||
* Will the tool need approval before calling production endpoints?
|
||||
* Should IBM Bob generate only suggestions, or should it directly modify the canvas?
|
||||
|
||||
---
|
||||
|
||||
## 13. Success Criteria
|
||||
|
||||
The MVP is successful if a user can:
|
||||
|
||||
* Create a simple UI without writing frontend code.
|
||||
* Configure at least one REST API endpoint.
|
||||
* Pass values from UI components into the API request.
|
||||
* Display the API response in another UI component.
|
||||
* Save and reload the project.
|
||||
* Use IBM Bob/watsonx to generate or improve part of the UI/API configuration.
|
||||
* Demonstrate a working Concert/RIA workflow launcher.
|
||||
|
||||
## Web Application Requirement
|
||||
|
||||
The system shall be delivered as a web-based application.
|
||||
|
||||
Users shall access the builder through a browser. No desktop client shall be required.
|
||||
|
||||
The application shall include:
|
||||
|
||||
* A browser-based frontend UI
|
||||
* A backend API service
|
||||
* Persistent project storage
|
||||
* A secure server-side API proxy for calling external REST endpoints
|
||||
|
||||
## Web Server / Hosting Requirement
|
||||
|
||||
The system should not implement its own production-grade web server.
|
||||
|
||||
Instead, it should run behind a standard web server or reverse proxy such as:
|
||||
|
||||
* NGINX
|
||||
* Apache HTTP Server
|
||||
* Caddy
|
||||
* IBM-approved internal hosting infrastructure
|
||||
|
||||
The application backend may include an embedded development server for local testing, but production deployment should use an external reverse proxy.
|
||||
|
||||
The reverse proxy should handle:
|
||||
|
||||
* HTTPS termination
|
||||
* Static frontend asset delivery
|
||||
* Routing requests to the backend service
|
||||
* Request size limits
|
||||
* Basic security headers
|
||||
* Optional access restrictions
|
||||
|
||||
Recommended deployment model:
|
||||
|
||||
```text
|
||||
Browser
|
||||
↓
|
||||
NGINX / Apache / Caddy / IBM-approved reverse proxy
|
||||
↓
|
||||
Frontend static assets + Backend API service
|
||||
↓
|
||||
REST API endpoints / Concert / RIA / other systems
|
||||
```
|
||||
|
||||
The backend service should focus on application logic, project storage, authentication handling, secret management, REST API proxying, and AI integration.
|
||||
|
||||
## Backend Persistence Requirement
|
||||
|
||||
The backend shall include persistent storage for project definitions and runtime metadata.
|
||||
|
||||
For the MVP, SQLite is the preferred database.
|
||||
|
||||
SQLite should store:
|
||||
|
||||
* Projects
|
||||
* Pages
|
||||
* UI components
|
||||
* Component layout data
|
||||
* REST API action definitions
|
||||
* Component-to-component bindings
|
||||
* Component-to-API bindings
|
||||
* Non-secret configuration
|
||||
* Basic execution history
|
||||
* Error/debug logs
|
||||
|
||||
Secrets should not be stored directly in plain text in SQLite.
|
||||
|
||||
The database should be treated as local application state for the MVP, not as an enterprise shared data platform.
|
||||
|
||||
## Recommended MVP Database
|
||||
|
||||
The MVP should use SQLite because it is:
|
||||
|
||||
* Simple to deploy
|
||||
* Easy to back up
|
||||
* Suitable for single-instance usage
|
||||
* Lightweight
|
||||
* Good enough for project metadata and configuration storage
|
||||
* Easier to package with a demo or proof-of-concept
|
||||
|
||||
## Future Database Options
|
||||
|
||||
If the project grows beyond MVP, the backend should be designed so SQLite can later be replaced by a full RDBMS such as PostgreSQL.
|
||||
|
||||
A full RDBMS may be required if the system needs:
|
||||
|
||||
* Multiple concurrent users
|
||||
* Team collaboration
|
||||
* Role-based access control
|
||||
* High availability
|
||||
* Centralized deployment
|
||||
* Enterprise backup/restore
|
||||
* Larger execution history
|
||||
* Reporting or analytics
|
||||
* Strict audit retention
|
||||
139
TASKS.md
Normal file
139
TASKS.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Conductor MVP Tasks
|
||||
|
||||
This document summarizes the current implementation and the remaining work required to complete the initial Conductor MVP. Completed items are based on the current workspace source code; the complete application has not yet been revalidated through a fresh full-stack test run.
|
||||
|
||||
## Completed
|
||||
|
||||
### Application foundation
|
||||
|
||||
- [x] Create the React and TypeScript frontend application.
|
||||
- [x] Create the Node.js, Express, and TypeScript backend service.
|
||||
- [x] Add SQLite project persistence.
|
||||
- [x] Add Dockerfiles and Docker Compose configuration.
|
||||
- [x] Add application navigation and a shared project context.
|
||||
- [x] Add the backend health endpoint.
|
||||
- [x] Add project create, list, read, update, and delete endpoints.
|
||||
- [x] Add frontend project creation, save, update, and load controls.
|
||||
|
||||
### Canonical project model
|
||||
|
||||
- [x] Define the canonical JSON project document.
|
||||
- [x] Add the versioned `0.1.0` JSON schema.
|
||||
- [x] Add shared frontend TypeScript project types.
|
||||
- [x] Add backend schema validation.
|
||||
- [x] Add valid and intentionally invalid example project definitions.
|
||||
- [x] Add a JSON editor that validates and applies changes to shared project state.
|
||||
- [x] Keep the Visual Editor and JSON Editor synchronized through the canonical document.
|
||||
|
||||
### Visual Editor
|
||||
|
||||
- [x] Add the component palette and canvas.
|
||||
- [x] Support component selection, movement, resizing, and deletion.
|
||||
- [x] Add common property editing for component name, label, placeholder, default value, visibility, disabled state, required state, position, and size where applicable.
|
||||
- [x] Detect duplicate component names in the editor.
|
||||
- [x] Implement the Label component.
|
||||
- [x] Implement the Button component.
|
||||
- [x] Implement the Text Input component.
|
||||
- [x] Implement the Dropdown component, including static option editing.
|
||||
- [x] Implement the Table component, including column and row editing.
|
||||
- [x] Implement the JSON Viewer component.
|
||||
|
||||
### REST actions and Preview runtime
|
||||
|
||||
- [x] Define REST actions in the project schema and TypeScript model.
|
||||
- [x] Support GET, POST, PUT, PATCH, and DELETE action definitions.
|
||||
- [x] Add the server-side REST proxy.
|
||||
- [x] Add URL, path, query, header, and body template handling.
|
||||
- [x] Add normalized proxy response envelopes and basic error handling.
|
||||
- [x] Add Preview mode.
|
||||
- [x] Execute button-bound REST actions in Preview mode.
|
||||
- [x] Resolve component and variable values in request templates.
|
||||
- [x] Store action, component, and variable runtime state separately from canonical project JSON.
|
||||
- [x] Implement response bindings to Label and JSON Viewer values.
|
||||
- [x] Implement response bindings to Dropdown options.
|
||||
- [x] Implement response bindings to Table rows.
|
||||
- [x] Implement response bindings to project variables.
|
||||
- [x] Support Table row selection at runtime.
|
||||
- [x] Add binding and action diagnostics in the Inspector.
|
||||
- [x] Add frontend unit tests for request templates, bindings, variables, dropdowns, and tables.
|
||||
|
||||
## Remaining MVP Tasks
|
||||
|
||||
### Missing MVP GUI components
|
||||
|
||||
- [ ] Implement the Text Area component in the palette, canvas, property editor, Preview runtime, and tests.
|
||||
- [ ] Implement the Checkbox component in the palette, canvas, property editor, Preview runtime, and tests.
|
||||
- [ ] Implement the Radio Group component in the palette, canvas, property editor, Preview runtime, and tests.
|
||||
- [ ] Implement the Status/Message Panel component in the palette, canvas, property editor, Preview runtime, response bindings, and tests.
|
||||
- [ ] Implement the Container/Card component in the palette, canvas, property editor, Preview runtime, and tests.
|
||||
- [ ] Add a clear validation diagnostic until every schema-supported component type is supported by both the Visual Editor and Preview runtime.
|
||||
|
||||
### Visual configuration workflows
|
||||
|
||||
- [ ] Implement a visual REST Action editor for creating, editing, and deleting actions without hand-editing JSON.
|
||||
- [ ] Implement visual component-event configuration, including binding a button to an action.
|
||||
- [ ] Implement a visual project-binding editor for request inputs and response targets.
|
||||
- [ ] Implement page-load action configuration for automatically populated components.
|
||||
- [ ] Complete component-specific property controls and basic styling controls required by the MVP specification.
|
||||
|
||||
### Authentication and secrets
|
||||
|
||||
- [ ] Implement Basic authentication during proxy execution.
|
||||
- [ ] Implement Bearer token authentication during proxy execution.
|
||||
- [ ] Implement API-key header authentication during proxy execution.
|
||||
- [ ] Implement API-key query-parameter authentication during proxy execution.
|
||||
- [ ] Add secure server-side credential storage and secret references.
|
||||
- [ ] Ensure exported project JSON excludes secret values.
|
||||
- [ ] Mask credentials and sensitive values in logs and error messages.
|
||||
|
||||
### Proxy security and observability
|
||||
|
||||
- [ ] Add permitted-origin or endpoint allowlisting for proxied requests.
|
||||
- [ ] Add SSRF protections and stricter URL validation.
|
||||
- [ ] Validate and sanitize forwarded request headers.
|
||||
- [ ] Persist sanitized API execution history, including timestamp, method, endpoint, status, duration, and error details.
|
||||
- [ ] Add a way to inspect basic execution history for troubleshooting.
|
||||
|
||||
### Validation and error handling
|
||||
|
||||
- [ ] Validate the canonical project document on every save operation.
|
||||
- [ ] Prevent invalid project documents from being persisted.
|
||||
- [ ] Add user-facing Preview error states for all supported component targets.
|
||||
- [ ] Reconcile schema descriptions and examples with the canonical `project.bindings` response-mapping model.
|
||||
- [ ] Remove new uses of the deprecated `action.responseMapping` field.
|
||||
|
||||
### Testing and release validation
|
||||
|
||||
- [ ] Add backend tests for health, project CRUD, schema validation, and proxy input validation.
|
||||
- [ ] Add proxy integration tests for successful responses, upstream errors, malformed responses, and timeouts.
|
||||
- [ ] Add persistence round-trip tests for complete canonical project documents.
|
||||
- [ ] Add editor tests for every MVP component and its property controls.
|
||||
- [ ] Add end-to-end tests covering project creation, visual editing, action execution, response mapping, saving, and loading.
|
||||
- [ ] Run frontend and backend TypeScript checks successfully.
|
||||
- [ ] Run all frontend and backend automated tests successfully.
|
||||
- [ ] Run schema validation against every valid and invalid example successfully.
|
||||
- [ ] Produce successful frontend and backend production builds.
|
||||
- [ ] Run the full application through Docker Compose and complete the documented integration checklist.
|
||||
|
||||
### MVP scope decision
|
||||
|
||||
- [ ] Decide whether IBM Bob/watsonx integration is required for the initial MVP release or is a post-MVP enhancement.
|
||||
- [ ] If AI is required for MVP, implement AI-assisted generation or refinement of canonical project JSON with user review before changes are applied.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] Designate the `docs/` copies of project documentation as authoritative and remove or redirect duplicate root-level copies.
|
||||
- [ ] Update the README documentation index to include the schema and response-mapping documents.
|
||||
- [ ] Update the build plan with the current implementation status and remaining milestone order.
|
||||
- [ ] Remove resolved questions from the requirements document or record their architectural decisions.
|
||||
- [ ] Publish a final MVP acceptance checklist tied to tested user workflows.
|
||||
|
||||
## Post-MVP Ideas
|
||||
|
||||
- [ ] Add multi-page application support.
|
||||
- [ ] Add OAuth 2.0 and IBM Cloud IAM authentication.
|
||||
- [ ] Add OpenAPI import and generated forms/actions.
|
||||
- [ ] Add reusable templates, themes, and component libraries.
|
||||
- [ ] Add version history, Git integration, and team collaboration.
|
||||
- [ ] Add role-based access control and enterprise audit retention.
|
||||
- [ ] Add tabs, modal dialogs, date pickers, file uploads, charts, and progress indicators.
|
||||
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
data/*.db
|
||||
data/*.db-wal
|
||||
data/*.db-shm
|
||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# better-sqlite3 requires a native compile step via node-gyp.
|
||||
# Alpine does not ship these tools by default, so install them first.
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
0
backend/data/.gitkeep
Normal file
0
backend/data/.gitkeep
Normal file
2234
backend/package-lock.json
generated
Normal file
2234
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/package.json
Normal file
28
backend/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "conductor-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"express": "^4.19.2",
|
||||
"morgan": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.14.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"allowScripts": {
|
||||
"better-sqlite3@12.11.1": true
|
||||
}
|
||||
}
|
||||
49
backend/src/app.ts
Normal file
49
backend/src/app.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import express, { Application, Request, Response, NextFunction } from 'express';
|
||||
import morgan from 'morgan';
|
||||
|
||||
import healthRouter from './routes/health';
|
||||
import projectsRouter from './routes/projects';
|
||||
import validateRouter from './routes/validate';
|
||||
import proxyRouter from './routes/proxy';
|
||||
|
||||
const app: Application = express();
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use(express.json());
|
||||
app.use(morgan('dev'));
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/health', healthRouter);
|
||||
app.use('/api/projects/validate', validateRouter);
|
||||
app.use('/api/projects', projectsRouter);
|
||||
app.use('/api/proxy/execute', proxyRouter);
|
||||
|
||||
// ── Catch-all 404 ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// ── JSON syntax error handler ─────────────────────────────────────────────────
|
||||
// Catches malformed JSON bodies before the generic 500 handler sees them.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
app.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
|
||||
if (err instanceof SyntaxError && 'body' in err) {
|
||||
res.status(400).json({ error: 'Invalid JSON: request body could not be parsed.' });
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
// ── Global error handler ──────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
export default app;
|
||||
17
backend/src/db/database.ts
Normal file
17
backend/src/db/database.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Store the database file in backend/data/ — easy to volume-mount and back up
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'conductor.db');
|
||||
|
||||
// Ensure the data directory exists before opening the file
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// WAL mode gives better read/write concurrency for a single-file SQLite database
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
export default db;
|
||||
20
backend/src/db/init.ts
Normal file
20
backend/src/db/init.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import db from './database';
|
||||
|
||||
/**
|
||||
* Creates all required tables if they do not already exist.
|
||||
* Safe to call on every startup — uses IF NOT EXISTS throughout.
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
project_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
console.log('Database initialised');
|
||||
}
|
||||
104
backend/src/db/projects.ts
Normal file
104
backend/src/db/projects.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import db from './database';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProjectRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
project_json: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CreateProjectInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
project_json?: string;
|
||||
};
|
||||
|
||||
export type UpdateProjectInput = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
project_json?: string;
|
||||
};
|
||||
|
||||
// Minimal valid project definition created when none is supplied on POST
|
||||
const DEFAULT_PROJECT_JSON = JSON.stringify({
|
||||
schemaVersion: '0.1.0',
|
||||
project: {
|
||||
name: '',
|
||||
pages: [],
|
||||
actions: [],
|
||||
variables: {},
|
||||
settings: {},
|
||||
},
|
||||
});
|
||||
|
||||
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const stmtList = db.prepare<[], ProjectRow>(`
|
||||
SELECT id, name, description, project_json, created_at, updated_at
|
||||
FROM projects
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
const stmtGetById = db.prepare<[number], ProjectRow>(`
|
||||
SELECT id, name, description, project_json, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const stmtInsert = db.prepare<[string, string, string], { lastInsertRowid: number }>(`
|
||||
INSERT INTO projects (name, description, project_json)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const stmtUpdate = db.prepare<[string, string, string, number], void>(`
|
||||
UPDATE projects
|
||||
SET name = ?, description = ?, project_json = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const stmtDelete = db.prepare<[number], void>(`
|
||||
DELETE FROM projects WHERE id = ?
|
||||
`);
|
||||
|
||||
// ── Data access functions ─────────────────────────────────────────────────────
|
||||
|
||||
export function listProjects(): ProjectRow[] {
|
||||
return stmtList.all();
|
||||
}
|
||||
|
||||
export function getProjectById(id: number): ProjectRow | undefined {
|
||||
return stmtGetById.get(id);
|
||||
}
|
||||
|
||||
export function createProject(input: CreateProjectInput): ProjectRow {
|
||||
const { name, description = '', project_json = DEFAULT_PROJECT_JSON } = input;
|
||||
// Embed the project name into the default JSON so it is consistent
|
||||
const json =
|
||||
project_json === DEFAULT_PROJECT_JSON
|
||||
? JSON.stringify({ ...JSON.parse(DEFAULT_PROJECT_JSON), project: { ...JSON.parse(DEFAULT_PROJECT_JSON).project, name } })
|
||||
: project_json;
|
||||
const result = stmtInsert.run(name, description, json);
|
||||
return getProjectById(result.lastInsertRowid as number) as ProjectRow;
|
||||
}
|
||||
|
||||
export function updateProject(id: number, input: UpdateProjectInput): ProjectRow | undefined {
|
||||
const existing = getProjectById(id);
|
||||
if (!existing) return undefined;
|
||||
stmtUpdate.run(
|
||||
input.name ?? existing.name,
|
||||
input.description ?? existing.description,
|
||||
input.project_json ?? existing.project_json,
|
||||
id,
|
||||
);
|
||||
return getProjectById(id);
|
||||
}
|
||||
|
||||
export function deleteProject(id: number): boolean {
|
||||
const result = stmtDelete.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
10
backend/src/index.ts
Normal file
10
backend/src/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import app from './app';
|
||||
import { initDatabase } from './db/init';
|
||||
|
||||
const PORT = process.env.PORT ?? 4000;
|
||||
|
||||
initDatabase();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Conductor backend listening on port ${PORT}`);
|
||||
});
|
||||
69
backend/src/lib/validateProject.ts
Normal file
69
backend/src/lib/validateProject.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import Ajv2020 from 'ajv/dist/2020';
|
||||
import addFormats from 'ajv-formats';
|
||||
|
||||
// ── Schema loading ────────────────────────────────────────────────────────────
|
||||
|
||||
// Resolve the shared schema at runtime so we stay inside rootDir: "src" for
|
||||
// TypeScript compilation while still reading the canonical schema file.
|
||||
const SCHEMA_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../../shared/schemas/conductor-project.schema.json',
|
||||
);
|
||||
|
||||
let _validate: ReturnType<Ajv2020['compile']> | null = null;
|
||||
|
||||
function getValidator(): ReturnType<Ajv2020['compile']> {
|
||||
if (_validate) return _validate;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(SCHEMA_PATH, 'utf-8');
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`[validateProject] Cannot read schema file at "${SCHEMA_PATH}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const schema = JSON.parse(raw) as object;
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
|
||||
_validate = ajv.compile(schema);
|
||||
return _validate;
|
||||
}
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ValidationError = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ValidationResult =
|
||||
| { valid: true }
|
||||
| { valid: false; errors: ValidationError[] };
|
||||
|
||||
// ── Validation function ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validates `doc` against the Conductor project JSON schema.
|
||||
*
|
||||
* Returns `{ valid: true }` on success, or `{ valid: false, errors }` with a
|
||||
* structured list of every schema violation found.
|
||||
*/
|
||||
export function validateProjectDocument(doc: unknown): ValidationResult {
|
||||
const validate = getValidator();
|
||||
const ok = validate(doc);
|
||||
|
||||
if (ok) return { valid: true };
|
||||
|
||||
const errors: ValidationError[] = (validate.errors ?? []).map((e) => ({
|
||||
path: e.instancePath || '(root)',
|
||||
message: e.message ?? 'Unknown validation error',
|
||||
}));
|
||||
|
||||
return { valid: false, errors };
|
||||
}
|
||||
13
backend/src/routes/health.ts
Normal file
13
backend/src/routes/health.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
service: 'conductor-backend',
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
93
backend/src/routes/projects.ts
Normal file
93
backend/src/routes/projects.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
listProjects,
|
||||
getProjectById,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from '../db/projects';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/projects — list all projects
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const projects = listProjects();
|
||||
res.json(projects);
|
||||
});
|
||||
|
||||
// POST /api/projects — create a project
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const { name, description, project_json } = req.body as Record<string, unknown>;
|
||||
|
||||
if (typeof name !== 'string' || name.trim() === '') {
|
||||
res.status(400).json({ error: '`name` is required and must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const project = createProject({
|
||||
name: name.trim(),
|
||||
description: typeof description === 'string' ? description : undefined,
|
||||
project_json: typeof project_json === 'string' ? project_json : undefined,
|
||||
});
|
||||
|
||||
res.status(201).json(project);
|
||||
});
|
||||
|
||||
// GET /api/projects/:id — get a single project
|
||||
router.get('/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: 'Invalid project id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const project = getProjectById(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: 'Project not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
// PUT /api/projects/:id — update a project
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: 'Invalid project id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, description, project_json } = req.body as Record<string, unknown>;
|
||||
const project = updateProject(id, {
|
||||
name: typeof name === 'string' ? name.trim() : undefined,
|
||||
description: typeof description === 'string' ? description : undefined,
|
||||
project_json: typeof project_json === 'string' ? project_json : undefined,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({ error: 'Project not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:id — delete a project
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: 'Invalid project id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = deleteProject(id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Project not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
238
backend/src/routes/proxy.ts
Normal file
238
backend/src/routes/proxy.ts
Normal file
@ -0,0 +1,238 @@
|
||||
/**
|
||||
* POST /api/proxy/execute
|
||||
*
|
||||
* Executes a REST action server-side and returns the proxied response.
|
||||
*
|
||||
* The frontend never calls external endpoints directly; all REST actions are
|
||||
* routed through this handler so that:
|
||||
* - credentials can be injected server-side in a later step
|
||||
* - CORS restrictions on the target API are irrelevant to the browser
|
||||
* - a single extension point exists for allowlisting and audit logging
|
||||
*
|
||||
* ── URL allowlisting ──────────────────────────────────────────────────────────
|
||||
* TODO(security): For production, add an allowlist of permitted target origins
|
||||
* (e.g. from project settings or environment config) and reject requests whose
|
||||
* URL does not match. The structure is already in place — add the check just
|
||||
* before the fetch() call in executeRequest().
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/**
|
||||
* Headers from the upstream response that are safe to forward to the client.
|
||||
* Omit hop-by-hop headers and any that could leak internal infrastructure
|
||||
* details (e.g. X-Powered-By, Server).
|
||||
*/
|
||||
const FORWARDED_RESPONSE_HEADERS = new Set([
|
||||
'content-type',
|
||||
'content-length',
|
||||
'content-encoding',
|
||||
'cache-control',
|
||||
'etag',
|
||||
'last-modified',
|
||||
'x-request-id',
|
||||
'x-correlation-id',
|
||||
'x-ratelimit-limit',
|
||||
'x-ratelimit-remaining',
|
||||
'x-ratelimit-reset',
|
||||
]);
|
||||
|
||||
/** Request timeout in milliseconds. */
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RestActionInput {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
queryParameters?: Record<string, string>;
|
||||
pathParameters?: Record<string, string>;
|
||||
bodyTemplate?: string;
|
||||
authenticationType?: string;
|
||||
}
|
||||
|
||||
interface ProxySuccessResponse {
|
||||
ok: true;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface ProxyErrorResponse {
|
||||
ok: false;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
type ProxyResponse = ProxySuccessResponse | ProxyErrorResponse;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Substitutes {{paramName}} placeholders in a template string with values from
|
||||
* the supplied map. Unmatched placeholders are left as-is so downstream errors
|
||||
* are clearly attributable to missing parameters rather than silent empty strings.
|
||||
*/
|
||||
function applyTemplate(template: string, params: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
|
||||
return Object.prototype.hasOwnProperty.call(params, key) ? params[key] : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the final request URL by:
|
||||
* 1. substituting path parameters into the URL template
|
||||
* 2. appending query parameters as a URLSearchParams string
|
||||
*/
|
||||
function buildUrl(
|
||||
urlTemplate: string,
|
||||
pathParameters: Record<string, string>,
|
||||
queryParameters: Record<string, string>,
|
||||
): string {
|
||||
const withPath = applyTemplate(urlTemplate, pathParameters);
|
||||
const qs = new URLSearchParams(queryParameters).toString();
|
||||
return qs ? `${withPath}${withPath.includes('?') ? '&' : '?'}${qs}` : withPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the HTTP request described by the action and returns a structured
|
||||
* proxy response regardless of whether the upstream responded with a 2xx or not.
|
||||
* Only throws on genuine network/timeout failures.
|
||||
*/
|
||||
async function executeRequest(action: RestActionInput): Promise<ProxyResponse> {
|
||||
const method = (action.method ?? '').toUpperCase();
|
||||
const urlTemplate = action.url ?? '';
|
||||
const pathParameters = action.pathParameters ?? {};
|
||||
const queryParameters = action.queryParameters ?? {};
|
||||
const headers = action.headers ?? {};
|
||||
const bodyTemplate = action.bodyTemplate ?? '';
|
||||
|
||||
// Build URL
|
||||
const finalUrl = buildUrl(urlTemplate, pathParameters, queryParameters);
|
||||
|
||||
// Build request init
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { ...headers },
|
||||
// Attach body only for methods that semantically support it
|
||||
body: method !== 'GET' && method !== 'DELETE' && bodyTemplate.trim()
|
||||
? bodyTemplate
|
||||
: undefined,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
// TODO(auth): inject credentials here based on action.authenticationType
|
||||
// For now only 'anonymous' is executed; other types are accepted by the model
|
||||
// but treated as anonymous until secret storage is implemented in a later step.
|
||||
|
||||
const startMs = Date.now();
|
||||
const upstream = await fetch(finalUrl, init);
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
// Collect safe response headers
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
upstream.headers.forEach((value, name) => {
|
||||
if (FORWARDED_RESPONSE_HEADERS.has(name.toLowerCase())) {
|
||||
responseHeaders[name.toLowerCase()] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Parse body: try JSON first, fall back to text
|
||||
const contentType = upstream.headers.get('content-type') ?? '';
|
||||
let body: unknown;
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
body = await upstream.json();
|
||||
} catch {
|
||||
body = await upstream.text();
|
||||
}
|
||||
} else {
|
||||
body = await upstream.text();
|
||||
}
|
||||
|
||||
return {
|
||||
ok: upstream.ok,
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
body,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Route handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/proxy/execute
|
||||
router.post('/', async (req: Request, res: Response): Promise<void> => {
|
||||
const action = req.body as RestActionInput | undefined | null;
|
||||
|
||||
// ── Input validation ───────────────────────────────────────────────────────
|
||||
|
||||
if (!action || typeof action !== 'object') {
|
||||
res.status(400).json({ error: 'Request body must be a JSON REST action definition.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!action.url || typeof action.url !== 'string' || !action.url.trim()) {
|
||||
res.status(400).json({ error: '`url` is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const method = (action.method ?? '').toUpperCase();
|
||||
if (!method) {
|
||||
res.status(400).json({ error: '`method` is required.' });
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_METHODS.has(method)) {
|
||||
res.status(400).json({
|
||||
error: `Unsupported HTTP method "${action.method}". Allowed: ${[...ALLOWED_METHODS].join(', ')}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Execution ──────────────────────────────────────────────────────────────
|
||||
|
||||
let result: ProxyResponse;
|
||||
try {
|
||||
result = await executeRequest({ ...action, method });
|
||||
} catch (err) {
|
||||
// Network-level failure (DNS, ECONNREFUSED, timeout, etc.)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isTimeout = err instanceof Error && err.name === 'TimeoutError';
|
||||
|
||||
console.error('[proxy] Network error executing action:', {
|
||||
url: action.url,
|
||||
method,
|
||||
error: message,
|
||||
});
|
||||
|
||||
res.status(502).json({
|
||||
error: isTimeout
|
||||
? `Request to "${action.url}" timed out after ${REQUEST_TIMEOUT_MS / 1000}s.`
|
||||
: `Network error reaching "${action.url}": ${message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-2xx responses from the upstream are not backend errors; return them
|
||||
// faithfully so the client can display the actual target status.
|
||||
res.status(200).json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
backend/src/routes/validate.ts
Normal file
46
backend/src/routes/validate.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { validateProjectDocument } from '../lib/validateProject';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/projects/validate
|
||||
//
|
||||
// Body: any JSON value (expected to be a Conductor project definition document)
|
||||
//
|
||||
// Returns:
|
||||
// 200 { valid: true }
|
||||
// — document satisfies the schema
|
||||
// 422 { valid: false, errors: [{ path, message }, ...] }
|
||||
// — document is syntactically valid JSON but fails schema validation
|
||||
// 400 { error: string }
|
||||
// — body is not parseable JSON (Express json() middleware rejects it
|
||||
// before the handler runs; this guard catches empty/null bodies)
|
||||
// 500 { error: string }
|
||||
// — unexpected internal error (schema file missing, AJV internal fault,
|
||||
// etc.); detail is logged server-side only
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
// express.json() has already parsed the body; if it is missing entirely
|
||||
// that means no JSON body was sent at all.
|
||||
if (req.body === undefined || req.body === null) {
|
||||
res.status(400).json({ error: 'Request body is required and must be a JSON document.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = validateProjectDocument(req.body);
|
||||
} catch (err) {
|
||||
console.error('[validate] Unexpected error in validateProjectDocument:', err);
|
||||
res.status(500).json({ error: 'Validation service unavailable. See server logs for details.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.valid) {
|
||||
res.status(200).json({ valid: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(422).json({ valid: false, errors: result.errors });
|
||||
});
|
||||
|
||||
export default router;
|
||||
16
backend/tsconfig.json
Normal file
16
backend/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
- PORT=4000
|
||||
- NODE_ENV=development
|
||||
# Data directory is mounted so the SQLite file survives container restarts.
|
||||
# shared/ is mounted read-only at /shared so the backend can resolve the
|
||||
# schema via path.resolve(__dirname, '../../../shared/schemas/...').
|
||||
# __dirname inside the container is /app/src/lib; three levels up is /,
|
||||
# so the full resolved path is /shared/schemas/conductor-project.schema.json.
|
||||
volumes:
|
||||
- ./backend/data:/app/data
|
||||
- ./shared:/shared:ro
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Used by the CRA dev-server proxy — must match the Compose service name
|
||||
- REACT_APP_API_URL=http://backend:4000
|
||||
- CI=false
|
||||
depends_on:
|
||||
- backend
|
||||
398
docs/ARCHITECTURE.md
Normal file
398
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,398 @@
|
||||
|
||||
# ARCHITECTURE.md
|
||||
|
||||
# Conductor Architecture
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Conductor is a web-based application builder for creating simple user interfaces backed by REST API endpoints.
|
||||
|
||||
The system consists of:
|
||||
|
||||
* React frontend
|
||||
* Backend API service
|
||||
* SQLite database
|
||||
* Canonical JSON project definition
|
||||
* Server-side REST API proxy
|
||||
* External web server or reverse proxy
|
||||
|
||||
The MVP shall function without AI assistance.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architectural Principle
|
||||
|
||||
The canonical source of truth for a Conductor application is a structured JSON project definition.
|
||||
|
||||
All editors and runtime views operate against this project definition.
|
||||
|
||||
This means:
|
||||
|
||||
* The Visual Editor modifies the JSON definition.
|
||||
* The JSON Editor modifies the same JSON definition.
|
||||
* Preview mode renders from the JSON definition.
|
||||
* Saved projects persist the JSON definition.
|
||||
* Future AI features will read and modify the JSON definition.
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended Stack
|
||||
|
||||
## Frontend
|
||||
|
||||
* React
|
||||
* TypeScript
|
||||
* Drag-and-drop canvas library
|
||||
* Monaco Editor or similar JSON editor
|
||||
* JSON schema validation
|
||||
|
||||
## Backend
|
||||
|
||||
* Node.js with Express/Fastify
|
||||
**or**
|
||||
* Python with FastAPI
|
||||
|
||||
Either is acceptable. Pick whichever is easiest for the team to support.
|
||||
|
||||
## Database
|
||||
|
||||
* SQLite for MVP
|
||||
* PostgreSQL as a future migration target
|
||||
|
||||
## Web Server
|
||||
|
||||
Production deployments should run behind:
|
||||
|
||||
* NGINX
|
||||
* Apache HTTP Server
|
||||
* Caddy
|
||||
* IBM-approved internal reverse proxy
|
||||
|
||||
The application should not implement its own production-grade web server.
|
||||
|
||||
---
|
||||
|
||||
## 4. High-Level Architecture
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
|-- Visual Editor
|
||||
|-- JSON Editor
|
||||
|-- Preview Runtime
|
||||
|
|
||||
v
|
||||
Backend API Service
|
||||
|
|
||||
|-- Project API
|
||||
|-- Validation API
|
||||
|-- REST Proxy API
|
||||
|-- Secret Handling
|
||||
|
|
||||
v
|
||||
SQLite Database
|
||||
|
|
||||
v
|
||||
Stored Project Definitions
|
||||
```
|
||||
|
||||
External API calls should flow through the backend proxy:
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
v
|
||||
Conductor Backend
|
||||
|
|
||||
v
|
||||
External REST API / Concert / RIA Endpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Major Components
|
||||
|
||||
## 5.1 Visual Editor
|
||||
|
||||
The Visual Editor provides the drag-and-drop interface.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Render canvas from project JSON
|
||||
* Add components
|
||||
* Move components
|
||||
* Resize components
|
||||
* Edit properties
|
||||
* Configure events
|
||||
* Configure bindings
|
||||
* Update canonical project JSON
|
||||
|
||||
## 5.2 JSON Editor
|
||||
|
||||
The JSON Editor provides direct access to the canonical project definition.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Display project JSON
|
||||
* Validate against schema
|
||||
* Show syntax errors
|
||||
* Apply edits to project state
|
||||
* Keep Visual Editor synchronized
|
||||
|
||||
## 5.3 Preview Runtime
|
||||
|
||||
Preview mode renders the project as an end user would experience it.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Render components from JSON
|
||||
* Execute configured events
|
||||
* Call backend proxy for REST actions
|
||||
* Apply response mappings
|
||||
* Display success/error states
|
||||
|
||||
## 5.4 Backend API Service
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Save projects
|
||||
* Load projects
|
||||
* Validate project definitions
|
||||
* Execute REST proxy requests
|
||||
* Store non-secret metadata
|
||||
* Manage secret references
|
||||
* Provide basic execution logs
|
||||
|
||||
## 5.5 REST API Proxy
|
||||
|
||||
The backend shall proxy external REST API calls.
|
||||
|
||||
Reasons:
|
||||
|
||||
* Avoid exposing secrets in the browser
|
||||
* Centralize authentication handling
|
||||
* Support API keys and bearer tokens safely
|
||||
* Normalize errors
|
||||
* Capture sanitized execution logs
|
||||
* Prepare for future endpoint allowlisting
|
||||
|
||||
## 5.6 Database Layer
|
||||
|
||||
SQLite stores:
|
||||
|
||||
* Project metadata
|
||||
* Canonical project JSON
|
||||
* Secret references
|
||||
* Basic execution history
|
||||
* Application settings
|
||||
|
||||
The backend should use a data access layer so SQLite can later be replaced with PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Flow
|
||||
|
||||
## 6.1 Editing Flow
|
||||
|
||||
```text
|
||||
User edits Visual Editor
|
||||
-> Project JSON updated
|
||||
-> Schema validation runs
|
||||
-> UI rerenders
|
||||
-> User saves project
|
||||
-> Backend persists JSON to SQLite
|
||||
```
|
||||
|
||||
## 6.2 JSON Editing Flow
|
||||
|
||||
```text
|
||||
User edits JSON
|
||||
-> JSON parsed
|
||||
-> Schema validation runs
|
||||
-> If valid, project state updates
|
||||
-> Visual Editor rerenders
|
||||
-> User saves project
|
||||
-> Backend persists JSON to SQLite
|
||||
```
|
||||
|
||||
## 6.3 API Execution Flow
|
||||
|
||||
```text
|
||||
User clicks button
|
||||
-> Event fires
|
||||
-> Binding resolves input values
|
||||
-> Backend REST proxy is called
|
||||
-> Backend calls external API
|
||||
-> Response returns to Preview Runtime
|
||||
-> Response mapping updates target component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Project Definition
|
||||
|
||||
The project definition should include:
|
||||
|
||||
* Project metadata
|
||||
* Pages
|
||||
* Components
|
||||
* Layout
|
||||
* Component properties
|
||||
* Events
|
||||
* Actions
|
||||
* Bindings
|
||||
* Variables
|
||||
* Settings
|
||||
* Schema version
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "example-project",
|
||||
"name": "Example Project",
|
||||
"pages": [],
|
||||
"actions": [],
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Model
|
||||
|
||||
The MVP security model should assume:
|
||||
|
||||
* Secrets must not be exposed to the frontend.
|
||||
* API calls requiring secrets must be executed by the backend.
|
||||
* Logs must mask sensitive values.
|
||||
* Project exports should exclude secrets by default.
|
||||
* Anonymous endpoints may be called without stored credentials.
|
||||
* Future deployments may require endpoint allowlisting.
|
||||
|
||||
Potential sensitive values:
|
||||
|
||||
* Authorization headers
|
||||
* API keys
|
||||
* Bearer tokens
|
||||
* Basic auth passwords
|
||||
* Session cookies
|
||||
|
||||
---
|
||||
|
||||
## 9. Deployment Model
|
||||
|
||||
The MVP deployment model should support:
|
||||
|
||||
```text
|
||||
Reverse Proxy
|
||||
-> Frontend static assets
|
||||
-> Backend API service
|
||||
-> SQLite database file
|
||||
```
|
||||
|
||||
A simple Docker Compose deployment is recommended for local demos and early internal use.
|
||||
|
||||
Future deployment options may include:
|
||||
|
||||
* Kubernetes
|
||||
* OpenShift
|
||||
* IBM Cloud Code Engine
|
||||
* Internal IBM hosting platform
|
||||
|
||||
---
|
||||
|
||||
## 10. Suggested Repository Structure
|
||||
|
||||
```text
|
||||
conductor/
|
||||
frontend/
|
||||
src/
|
||||
backend/
|
||||
src/
|
||||
docs/
|
||||
REQUIREMENTS.md
|
||||
NICE-TO-HAVE.md
|
||||
ARCHITECTURE.md
|
||||
examples/
|
||||
project-definitions/
|
||||
docker-compose.yml
|
||||
README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Initial API Surface
|
||||
|
||||
Potential backend endpoints:
|
||||
|
||||
```text
|
||||
GET /api/projects
|
||||
POST /api/projects
|
||||
GET /api/projects/:id
|
||||
PUT /api/projects/:id
|
||||
DELETE /api/projects/:id
|
||||
|
||||
POST /api/projects/:id/validate
|
||||
POST /api/proxy/execute
|
||||
GET /api/projects/:id/executions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Architectural Decisions
|
||||
|
||||
Initial decisions:
|
||||
|
||||
* Conductor is a web application.
|
||||
* Conductor uses a React frontend.
|
||||
* Conductor uses a backend API service.
|
||||
* Conductor uses SQLite for MVP persistence.
|
||||
* Conductor stores projects as canonical JSON documents.
|
||||
* REST API calls flow through the backend proxy.
|
||||
* Secrets are never intentionally exposed to the browser.
|
||||
* Production deployments use an external reverse proxy.
|
||||
* AI assistance is not required for MVP.
|
||||
|
||||
|
||||
|
||||
## Backend-Agnostic REST Integration
|
||||
|
||||
Conductor shall be designed as a backend-agnostic REST UI builder.
|
||||
|
||||
Although the initial target use case is IBM Concert Workflows / Rapid Infrastructure Automation, Conductor should not be tightly coupled to any single automation platform.
|
||||
|
||||
Any system that exposes reachable HTTP/REST endpoints may be used as an integration target.
|
||||
|
||||
Potential integration targets include:
|
||||
|
||||
* IBM Concert Workflows / Rapid Infrastructure Automation
|
||||
* Node-RED HTTP endpoints
|
||||
* Custom internal APIs
|
||||
* FastAPI, Flask, Express, or similar backend services
|
||||
* Other workflow or automation platforms with REST APIs
|
||||
|
||||
Conductor should treat external systems as REST action providers.
|
||||
|
||||
For the MVP, Conductor is responsible for:
|
||||
|
||||
* Rendering the user interface
|
||||
* Collecting user input
|
||||
* Calling configured REST endpoints through the backend proxy
|
||||
* Passing request parameters
|
||||
* Receiving responses
|
||||
* Mapping responses back into UI components
|
||||
|
||||
External systems are responsible for:
|
||||
|
||||
* Workflow execution
|
||||
* Automation logic
|
||||
* Business logic
|
||||
* External integrations
|
||||
* Long-running task handling
|
||||
|
||||
Conductor should avoid implementing workflow orchestration internally unless required by a future enhancement.
|
||||
435
docs/BUILD_AND_TEST_PLAN.md
Normal file
435
docs/BUILD_AND_TEST_PLAN.md
Normal file
@ -0,0 +1,435 @@
|
||||
# BUILD_AND_TEST_PLAN.md
|
||||
|
||||
# Conductor — Build and Test Plan
|
||||
|
||||
This document defines the incremental build steps for the Conductor MVP.
|
||||
Each step is self-contained and verifiable before the next begins.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Project Scaffold
|
||||
|
||||
**Goal:** Create the repository structure with placeholder files for frontend, backend, and docs.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `frontend/` — React + TypeScript project scaffold (no UI logic yet)
|
||||
- `backend/` — Node.js + Express + TypeScript project scaffold (no logic yet)
|
||||
- `docs/` — Documentation directory containing all spec files
|
||||
- `docker-compose.yml` — Root compose file wiring frontend and backend services
|
||||
- `README.md` — Root readme with project overview and local dev instructions
|
||||
- `.gitignore` — Ignores node_modules, build artifacts, SQLite files, .env files
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Confirm directory structure
|
||||
ls -1
|
||||
|
||||
# Confirm docs
|
||||
ls docs/
|
||||
|
||||
# Install dependencies
|
||||
cd frontend && npm install
|
||||
cd ../backend && npm install
|
||||
|
||||
# Confirm TypeScript compiles without errors
|
||||
cd frontend && npx tsc --noEmit
|
||||
cd ../backend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
### Does not include
|
||||
|
||||
- Backend health check endpoint
|
||||
- Frontend UI components
|
||||
- SQLite setup
|
||||
- REST proxy
|
||||
- Authentication
|
||||
- AI features
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Backend Foundation
|
||||
|
||||
**Goal:** Stand up a running Express server with a health check endpoint and SQLite connection.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `GET /api/health` returns `{ status: "ok" }`
|
||||
- SQLite database initialised on startup
|
||||
- Basic project table created in SQLite
|
||||
- Structured request logging (morgan or pino)
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
cd backend && npm run dev
|
||||
curl http://localhost:4000/api/health
|
||||
# Expected: { "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Project API (CRUD)
|
||||
|
||||
**Goal:** Implement create, read, update, delete for projects.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `POST /api/projects` — create a project
|
||||
- `GET /api/projects` — list all projects
|
||||
- `GET /api/projects/:id` — get a single project
|
||||
- `PUT /api/projects/:id` — update a project
|
||||
- `DELETE /api/projects/:id` — delete a project
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Create
|
||||
curl -X POST http://localhost:4000/api/projects \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Test Project","description":"My first project"}'
|
||||
|
||||
# List
|
||||
curl http://localhost:4000/api/projects
|
||||
|
||||
# Get by ID
|
||||
curl http://localhost:4000/api/projects/1
|
||||
|
||||
# Update
|
||||
curl -X PUT http://localhost:4000/api/projects/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Renamed Project"}'
|
||||
|
||||
# Delete
|
||||
curl -X DELETE http://localhost:4000/api/projects/1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Frontend Shell
|
||||
|
||||
**Goal:** React app loads, renders a basic shell layout, and communicates with the backend.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- App shell with header and placeholder content area
|
||||
- API client calling `GET /api/projects`
|
||||
- Projects listed in the UI (names only)
|
||||
- Development proxy configured so `/api/*` routes to backend
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
cd frontend && npm start
|
||||
# Open http://localhost:3000
|
||||
# Confirm project list renders (or empty state message)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Canvas and Component Palette
|
||||
|
||||
**Goal:** Drag-and-drop canvas renders and components can be placed.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Canvas area renders in the editor view
|
||||
- Component palette with: Button, Text Input, Text Area, Dropdown, Label, Table, JSON Viewer, Status Panel
|
||||
- Drag a component from the palette onto the canvas
|
||||
- Dropped component renders on canvas at drop position
|
||||
- Selected component shows a visual selection indicator
|
||||
|
||||
### Verification
|
||||
|
||||
- Open editor view
|
||||
- Drag Button onto canvas — button appears
|
||||
- Drag Text Input onto canvas — input appears
|
||||
- Click a component — selection indicator shows
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Project JSON Definition
|
||||
|
||||
**Goal:** Canvas state is represented as a canonical JSON project definition and persisted to the backend.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Canvas state serialises to project JSON on every change
|
||||
- JSON Editor panel displays live project JSON
|
||||
- Edits in JSON Editor update the canvas
|
||||
- Save button persists JSON to `PUT /api/projects/:id`
|
||||
- Load project from API on page load
|
||||
|
||||
### Verification
|
||||
|
||||
- Add a button to canvas
|
||||
- JSON Editor panel shows button in JSON
|
||||
- Manually edit button label in JSON Editor — canvas label updates
|
||||
- Click Save — reload page — project restores
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Component Properties Panel
|
||||
|
||||
**Goal:** Selecting a component opens a properties panel for editing its configuration.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Properties panel opens when a component is selected
|
||||
- Editable fields: name, label, placeholder, default value, visibility, disabled state
|
||||
- Changes in properties panel update canvas and project JSON
|
||||
|
||||
### Verification
|
||||
|
||||
- Place a Text Input
|
||||
- Select it — properties panel opens
|
||||
- Change label — canvas updates immediately
|
||||
- JSON Editor shows updated label
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — REST Action Configuration
|
||||
|
||||
**Goal:** Users can define REST API actions on a project.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Actions panel lists defined REST actions
|
||||
- Create action form: name, method, URL, headers, query params, body template, auth type
|
||||
- Actions saved as part of project JSON
|
||||
- Auth types: Anonymous, Basic, Bearer token, API key (header or query param)
|
||||
|
||||
### Verification
|
||||
|
||||
- Create a GET action pointing to `https://httpbin.org/get`
|
||||
- Save project
|
||||
- Reload — action persists
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Backend REST Proxy
|
||||
|
||||
**Goal:** Backend proxies REST API calls on behalf of the frontend.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `POST /api/proxy/execute` accepts action ID + runtime values
|
||||
- Backend resolves action definition from project JSON
|
||||
- Backend executes HTTP request to target endpoint
|
||||
- Response returned to frontend
|
||||
- Secrets/auth headers injected server-side, never exposed to browser
|
||||
- Basic execution log saved to SQLite
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/proxy/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"projectId":"1","actionId":"action-1","inputs":{}}'
|
||||
# Expected: proxied response from target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Bindings and Preview Mode
|
||||
|
||||
**Goal:** Components are bound to actions; Preview mode allows end-to-end interaction.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Button can be bound to a REST action
|
||||
- Dropdown can populate options from an API response
|
||||
- Response mapping: `apiResponse.fieldPath -> component.property`
|
||||
- Preview mode renders project as end-user view
|
||||
- API calls fire in preview mode; responses update target components
|
||||
|
||||
### Verification
|
||||
|
||||
- Bind Button to a GET action
|
||||
- In Preview: click button — API fires — JSON Viewer updates with response
|
||||
- Bind Dropdown to GET action returning list — dropdown populates on page load
|
||||
|
||||
---
|
||||
|
||||
## Step 10.5 — Project Persistence
|
||||
|
||||
**Goal:** Connect the Visual Editor to the backend Project CRUD API via a React Project Context.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `ProjectProvider` wraps the entire app; all editor views share one context
|
||||
- `ProjectContext` holds: current `ProjectDocument`, backend row ID, project name, dirty state, loading/saving state, project list, and all persistence operations
|
||||
- `ProjectToolbar` renders: project name (click-to-rename), **New**, **Save / Update**, **Load** buttons
|
||||
- Load picker lists all backend projects and loads the selected one
|
||||
- Saving serialises the full canonical project JSON and calls `PUT /api/projects/:id` (or `POST` on first save)
|
||||
- Loading calls `GET /api/projects/:id` and fully replaces context state
|
||||
- Toast notifications for: save succeeded, save failed, load succeeded, load failed, network error
|
||||
- Visual Editor renders from `ProjectContext` — no independent state
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Start backend
|
||||
cd backend && npm run dev
|
||||
|
||||
# Start frontend
|
||||
cd frontend && npm start
|
||||
```
|
||||
|
||||
- Open http://localhost:3000 → Visual Editor
|
||||
- Add components → click **Save** → toast confirms save; project appears in backend DB
|
||||
- Reload page → click **Load** → select project → canvas restores
|
||||
- Edit project name in toolbar → click **Update** → backend reflects new name
|
||||
- Click **New** → fresh canvas; previous project unaffected on backend
|
||||
|
||||
---
|
||||
|
||||
## Step 11 — Validation and Error Handling
|
||||
|
||||
**Goal:** Schema validation, user-facing error states, and proxy error normalisation.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Project JSON validated against schema on save
|
||||
- JSON Editor shows schema errors inline
|
||||
- Preview mode shows error state on failed API call
|
||||
- Proxy normalises and returns structured error responses
|
||||
|
||||
---
|
||||
|
||||
## Step 12 — Docker Compose Integration Test
|
||||
|
||||
**Goal:** Full stack runs via `docker-compose up`.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `docker-compose up` starts frontend and backend
|
||||
- Frontend accessible at `http://localhost:3000`
|
||||
- Backend accessible at `http://localhost:4000`
|
||||
- Full Step 10 verification passes against Docker stack
|
||||
|
||||
---
|
||||
|
||||
## Step 13 — REST Action Model
|
||||
|
||||
**Goal:** Add support for defining REST actions inside the canonical Conductor project JSON. This step is model-only — no execution, no backend proxy, no binding.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- JSON schema (`shared/schemas/conductor-project.schema.json`) fully defines the `Action` $def with all required and optional fields, all five authentication types, and correct validation rules
|
||||
- TypeScript type `RestAction` in `frontend/src/types/project.ts` mirrors the schema with JSDoc comments
|
||||
- `Project` type includes `actions: RestAction[]`
|
||||
- JSON Editor allows REST actions to be added or edited through the canonical project JSON (apply + validate flow)
|
||||
- `POST /api/projects/validate` validates REST actions against the schema
|
||||
- Example files:
|
||||
- `examples/project-definitions/valid-rest-actions.json` — seven actions covering all five `authenticationType` values
|
||||
- `examples/project-definitions/valid-full.json` — includes two REST actions with path parameters, query parameters, and body templates
|
||||
- `docs/SCHEMA.md` documents the correct `Action` shape (with `authenticationType`), field table, and validation commands for all example files
|
||||
|
||||
### REST Action Model
|
||||
|
||||
Each REST action supports the following fields:
|
||||
|
||||
| Field | Required | Description |
|
||||
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | ✅ | Unique identifier within the project. |
|
||||
| `name` | ✅ | Human-readable name shown in the UI. |
|
||||
| `description` | — | Optional description of the action's purpose. |
|
||||
| `method` | ✅ | HTTP method:`GET` · `POST` · `PUT` · `PATCH` · `DELETE` |
|
||||
| `url` | ✅ | Target URL template;`{{paramName}}` marks path parameter slots. |
|
||||
| `headers` | — | Static request headers. Values may use`{{variableName}}` syntax. |
|
||||
| `queryParameters` | — | URL query parameters. Values may use`{{variableName}}` syntax. |
|
||||
| `pathParameters` | — | Path segment substitutions for`{{paramName}}` URL placeholders. |
|
||||
| `bodyTemplate` | — | Request body template with`{{variableName}}` substitution slots. |
|
||||
| `authenticationType` | ✅ | One of:`anonymous` · `bearerToken` · `basicAuth` · `apiKeyHeader` · `apiKeyQueryParameter` |
|
||||
|
||||
### Does not include
|
||||
|
||||
- Backend REST proxy
|
||||
- REST action execution
|
||||
- Button-to-REST binding
|
||||
- Input-to-request binding
|
||||
- Response mapping execution
|
||||
- Secret storage
|
||||
- Authentication credential execution
|
||||
- AI features
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Install ajv-cli if not already installed
|
||||
npm install -g ajv-cli ajv-formats
|
||||
|
||||
# Validate the minimal example
|
||||
ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d examples/project-definitions/valid-minimal.json \
|
||||
--spec=draft2020
|
||||
|
||||
# Validate the full example (includes REST actions)
|
||||
ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d examples/project-definitions/valid-full.json \
|
||||
--spec=draft2020
|
||||
|
||||
# Validate the REST actions showcase (all five auth types)
|
||||
ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d examples/project-definitions/valid-rest-actions.json \
|
||||
--spec=draft2020
|
||||
|
||||
# TypeScript type check (frontend)
|
||||
cd frontend && npx tsc --noEmit
|
||||
|
||||
# TypeScript type check (backend)
|
||||
cd backend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
### Manual verification checklist
|
||||
|
||||
- [ ] Open the JSON Editor in the browser
|
||||
- [ ] Add a REST action to `project.actions` in the textarea:
|
||||
```json
|
||||
{
|
||||
"id": "action_test",
|
||||
"name": "Test Action",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": { "Accept": "application/json" },
|
||||
"queryParameters": {},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous"
|
||||
}
|
||||
```
|
||||
- [ ] Click **Apply** — validation passes, action count in sidebar increments
|
||||
- [ ] Click **Save** — project persists to backend
|
||||
- [ ] Reload page → **Load** project → REST action is present in JSON
|
||||
|
||||
|
||||
|
||||
## Validation Gate
|
||||
|
||||
Before proceeding from any step that changes the project JSON model, JSON schema, component model, REST action model, binding model, or persistence behavior, the change must be validated.
|
||||
|
||||
Validation must include:
|
||||
|
||||
- Running schema validation against valid examples.
|
||||
- Running schema validation against invalid examples.
|
||||
- Testing at least one realistic project document that uses the newly added feature.
|
||||
- Confirming that the frontend JSON Editor accepts the document.
|
||||
- Confirming that save/load preserves the document.
|
||||
- Confirming that the runtime consumes the same model that the schema validates.
|
||||
|
||||
A step is not complete until the schema, frontend editor, backend validator, and runtime agree on the same JSON shape.
|
||||
|
||||
Do not proceed to the next step if:
|
||||
|
||||
- Example JSON fails validation.
|
||||
- The JSON Editor reports a validation error.
|
||||
- The runtime expects a different structure than the schema allows.
|
||||
- The backend validation endpoint returns HTTP 500.
|
||||
- A new model field is accepted by the frontend but rejected by the backend.
|
||||
|
||||
---
|
||||
155
docs/NICE-TO-HAVE.md
Normal file
155
docs/NICE-TO-HAVE.md
Normal file
@ -0,0 +1,155 @@
|
||||
# NICE-TO-HAVE.md
|
||||
|
||||
# Future Enhancements for Conductor
|
||||
|
||||
The following features are considered desirable enhancements but are not required for the initial MVP.
|
||||
|
||||
---
|
||||
|
||||
# AI-Assisted Development
|
||||
|
||||
Conductor should be designed so that AI capabilities can be integrated without changing the underlying project architecture.
|
||||
|
||||
AI assistance should operate by reading and modifying the project's canonical JSON definition.
|
||||
|
||||
The application shall remain fully functional when AI services are unavailable.
|
||||
|
||||
---
|
||||
|
||||
## AI Chat
|
||||
|
||||
Provide an integrated conversational interface for interacting with a project.
|
||||
|
||||
Example requests:
|
||||
|
||||
* Build a login page.
|
||||
* Add a table below the dropdown.
|
||||
* Connect this button to a REST endpoint.
|
||||
* Explain what this page does.
|
||||
* Rename all references to "Environment" as "Target Environment."
|
||||
* Improve the layout.
|
||||
* Add validation to required fields.
|
||||
* Document this application.
|
||||
|
||||
The AI should generate proposed project changes rather than modifying the project without user approval.
|
||||
|
||||
---
|
||||
|
||||
## AI Project Generation
|
||||
|
||||
Allow users to create an application from a natural language description.
|
||||
|
||||
Example:
|
||||
|
||||
> Build a form that accepts a hostname, environment, and owner, then calls a Rapid Infrastructure Automation workflow and displays the results.
|
||||
|
||||
The AI should generate:
|
||||
|
||||
* Pages
|
||||
* Components
|
||||
* Layout
|
||||
* REST actions
|
||||
* Bindings
|
||||
* Default styling
|
||||
|
||||
The generated project should immediately open in the Visual Editor for refinement.
|
||||
|
||||
---
|
||||
|
||||
## AI-Assisted REST Configuration
|
||||
|
||||
The AI may assist users by:
|
||||
|
||||
* Creating REST action definitions
|
||||
* Suggesting request bodies
|
||||
* Generating headers
|
||||
* Creating authentication configurations
|
||||
* Mapping API responses to UI components
|
||||
* Suggesting validation rules
|
||||
|
||||
---
|
||||
|
||||
## AI Documentation
|
||||
|
||||
Generate documentation from an existing project.
|
||||
|
||||
Potential outputs include:
|
||||
|
||||
* Markdown documentation
|
||||
* API documentation
|
||||
* End-user documentation
|
||||
* Administrator documentation
|
||||
* Project summaries
|
||||
|
||||
---
|
||||
|
||||
## AI Refactoring
|
||||
|
||||
Allow the AI to improve an existing project.
|
||||
|
||||
Examples include:
|
||||
|
||||
* Simplifying layouts
|
||||
* Removing unused components
|
||||
* Consolidating duplicate REST actions
|
||||
* Improving naming consistency
|
||||
* Reorganizing pages
|
||||
* Suggesting accessibility improvements
|
||||
|
||||
---
|
||||
|
||||
## AI Validation
|
||||
|
||||
The AI may analyze projects for potential issues, including:
|
||||
|
||||
* Missing bindings
|
||||
* Invalid REST configurations
|
||||
* Unused components
|
||||
* Circular dependencies
|
||||
* Missing required inputs
|
||||
* Security concerns
|
||||
* Inconsistent naming
|
||||
|
||||
---
|
||||
|
||||
## AI Explainability
|
||||
|
||||
Allow users to ask questions about an existing project.
|
||||
|
||||
Examples include:
|
||||
|
||||
* What happens when this button is clicked?
|
||||
* Which components call REST APIs?
|
||||
* Which workflow launches this action?
|
||||
* Where is this value used?
|
||||
* Why is this field disabled?
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI Integration
|
||||
|
||||
Allow users to import an OpenAPI specification and automatically generate:
|
||||
|
||||
* REST action definitions
|
||||
* Forms
|
||||
* CRUD pages
|
||||
* Tables
|
||||
* Documentation
|
||||
|
||||
---
|
||||
|
||||
## Additional Future Enhancements
|
||||
|
||||
* Multi-page applications
|
||||
* Reusable component libraries
|
||||
* Themes and styling templates
|
||||
* Workflow templates
|
||||
* Project version history
|
||||
* Git integration
|
||||
* Team collaboration
|
||||
* Role-based access control
|
||||
* Plugin architecture
|
||||
* Additional authentication providers
|
||||
* Internationalization
|
||||
* Accessibility auditing
|
||||
* Application packaging and deployment
|
||||
692
docs/REQUIREMENTS.md
Normal file
692
docs/REQUIREMENTS.md
Normal file
@ -0,0 +1,692 @@
|
||||
# REQUIREMENTS.md
|
||||
|
||||
# Project: AI-Assisted REST UI Builder
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Build a lightweight UI builder that allows users to create simple frontend applications backed by REST API endpoints, with a focus on IBM Concert Workflows / Rapid Infrastructure Automation use cases.
|
||||
|
||||
The tool should allow users to drag and drop UI elements onto a canvas, configure those elements, connect them to REST API calls, and allow one UI element to update another based on API responses or user interaction.
|
||||
|
||||
IBM Bob / watsonx should assist with generating, configuring, documenting, and refining these UI applications.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
The system should:
|
||||
|
||||
* Allow users to visually build simple UI workflows.
|
||||
* Support drag-and-drop placement of UI components on a canvas.
|
||||
* Allow UI components to call REST API endpoints.
|
||||
* Support multiple authentication types, including anonymous access.
|
||||
* Allow API responses to update other UI components.
|
||||
* Make it easier for CSMs, architects, and technical users to build working demos or operational tools without hand-coding every frontend.
|
||||
* Use AI assistance to speed up configuration, explanation, and generation of UI/API bindings.
|
||||
|
||||
---
|
||||
|
||||
## 3. Non-Goals
|
||||
|
||||
The system is not intended to be a full Appsmith replacement.
|
||||
|
||||
The MVP will not include:
|
||||
|
||||
* Full database integrations.
|
||||
* Complex multi-user application publishing.
|
||||
* Advanced permissions or RBAC.
|
||||
* Marketplace-style widget plugins.
|
||||
* Pixel-perfect design tooling.
|
||||
* Full workflow orchestration.
|
||||
* Public SaaS hosting.
|
||||
* Complex version control or branching.
|
||||
* Enterprise-grade audit logging.
|
||||
|
||||
---
|
||||
|
||||
## 4. Primary Users
|
||||
|
||||
### 4.1 Customer Success Managers
|
||||
|
||||
CSMs may use the tool to build demos, internal utilities, workflow launchers, and simple operational dashboards.
|
||||
|
||||
### 4.2 Technical Sellers / Architects
|
||||
|
||||
Technical users may use the tool to demonstrate how REST APIs, Concert Workflows, or Rapid Infrastructure Automation endpoints can be exposed through a simple UI.
|
||||
|
||||
### 4.3 Internal Operators
|
||||
|
||||
Internal IBM teams may use the tool to create small utilities for repeatable operational tasks.
|
||||
|
||||
---
|
||||
|
||||
## 5. Core Use Cases
|
||||
|
||||
### 5.1 Build a Simple Workflow Launcher
|
||||
|
||||
A user creates a page with:
|
||||
|
||||
* A dropdown to select an environment.
|
||||
* A text input for parameters.
|
||||
* A button to call a REST endpoint.
|
||||
* A status/output panel showing the response.
|
||||
|
||||
### 5.2 Build a Dependent Dropdown UI
|
||||
|
||||
A user creates:
|
||||
|
||||
* A dropdown that calls an API to retrieve available environments.
|
||||
* A second dropdown that updates based on the selected environment.
|
||||
* A button that launches a workflow using both selected values.
|
||||
|
||||
### 5.3 Build a Read-Only API Dashboard
|
||||
|
||||
A user creates:
|
||||
|
||||
* A table connected to a REST endpoint.
|
||||
* A refresh button.
|
||||
* A details panel that updates when a table row is selected.
|
||||
|
||||
### 5.4 Build a Concert/RIA Workflow Form
|
||||
|
||||
A user creates:
|
||||
|
||||
* Input fields for required workflow parameters.
|
||||
* Authentication configuration.
|
||||
* A submit button that calls the workflow endpoint.
|
||||
* A response display showing success, failure, or returned data.
|
||||
|
||||
---
|
||||
|
||||
## 6. Functional Requirements
|
||||
|
||||
## 6.1 Canvas Builder
|
||||
|
||||
The system shall provide a visual canvas where users can place UI elements.
|
||||
|
||||
The canvas shall support:
|
||||
|
||||
* Drag-and-drop widget placement.
|
||||
* Selecting widgets.
|
||||
* Moving widgets.
|
||||
* Resizing widgets.
|
||||
* Deleting widgets.
|
||||
* Basic alignment or snapping.
|
||||
* Saving the canvas layout.
|
||||
|
||||
## 6.2 UI Components
|
||||
|
||||
The MVP shall support the following UI components:
|
||||
|
||||
* Button
|
||||
* Text input
|
||||
* Text area
|
||||
* Dropdown/select
|
||||
* Checkbox
|
||||
* Radio group
|
||||
* Static text/label
|
||||
* Table
|
||||
* JSON response viewer
|
||||
* Status/message panel
|
||||
* Container/card
|
||||
|
||||
Future components may include:
|
||||
|
||||
* Tabs
|
||||
* Modal dialog
|
||||
* Date picker
|
||||
* File upload
|
||||
* Chart
|
||||
* Progress indicator
|
||||
|
||||
## 6.3 Component Configuration
|
||||
|
||||
Each component shall have configurable properties.
|
||||
|
||||
Common properties should include:
|
||||
|
||||
* Component name / ID
|
||||
* Label
|
||||
* Placeholder text
|
||||
* Default value
|
||||
* Visibility
|
||||
* Disabled state
|
||||
* Required state
|
||||
* Styling basics
|
||||
|
||||
Component-specific properties may include:
|
||||
|
||||
* Dropdown options
|
||||
* Table columns
|
||||
* Button action
|
||||
* API binding
|
||||
* Response mapping
|
||||
* Validation rules
|
||||
|
||||
## 6.4 REST API Configuration
|
||||
|
||||
The system shall allow users to define REST API actions.
|
||||
|
||||
Each REST API action shall support:
|
||||
|
||||
* Name
|
||||
* Description
|
||||
* HTTP method: GET, POST, PUT, PATCH, DELETE
|
||||
* Endpoint URL
|
||||
* Headers
|
||||
* Query parameters
|
||||
* Path parameters
|
||||
* Request body
|
||||
* Authentication type
|
||||
* Timeout setting
|
||||
* Expected response format
|
||||
|
||||
The system should support JSON request and response bodies in the MVP.
|
||||
|
||||
## 6.5 Authentication Support
|
||||
|
||||
The system shall support multiple authentication types for REST API calls.
|
||||
|
||||
MVP authentication types:
|
||||
|
||||
* Anonymous / no authentication
|
||||
* Basic authentication
|
||||
* Bearer token
|
||||
* API key in header
|
||||
* API key in query parameter
|
||||
|
||||
Future authentication types may include:
|
||||
|
||||
* OAuth 2.0
|
||||
* mTLS
|
||||
* IBM Cloud IAM token flow
|
||||
* Custom authentication script or pre-request action
|
||||
|
||||
## 6.6 UI-to-API Binding
|
||||
|
||||
The system shall allow a UI component to trigger a REST API action.
|
||||
|
||||
Examples:
|
||||
|
||||
* Button click calls an API endpoint.
|
||||
* Dropdown selection calls an API endpoint.
|
||||
* Page load calls an API endpoint.
|
||||
* Table row selection updates another component.
|
||||
|
||||
The system shall allow component values to be used in API requests.
|
||||
|
||||
Examples:
|
||||
|
||||
* Text input value used in a POST body.
|
||||
* Dropdown value used as a query parameter.
|
||||
* Table row value used in a path parameter.
|
||||
|
||||
## 6.7 Component-to-Component Updates
|
||||
|
||||
The system shall allow one component to update another component.
|
||||
|
||||
Examples:
|
||||
|
||||
* Dropdown A updates the options in Dropdown B.
|
||||
* Button response updates a JSON viewer.
|
||||
* Table selection updates a details panel.
|
||||
* API response updates a status/message component.
|
||||
|
||||
The MVP should support simple response mapping using JSON paths.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "apiResponse.environments",
|
||||
"target": "environmentDropdown.options"
|
||||
}
|
||||
```
|
||||
|
||||
## 6.8 AI Assistance
|
||||
|
||||
IBM Bob / watsonx should assist users by:
|
||||
|
||||
* Suggesting UI layouts from a natural language prompt.
|
||||
* Generating REST API action definitions.
|
||||
* Explaining API responses.
|
||||
* Suggesting response mappings.
|
||||
* Helping generate sample request bodies.
|
||||
* Helping troubleshoot API errors.
|
||||
* Producing documentation for a generated UI.
|
||||
* Suggesting improvements to usability.
|
||||
|
||||
Example prompt:
|
||||
|
||||
> Build a form that lets me select an environment, enter a hostname, and call a Rapid Infrastructure Automation workflow to provision monitoring.
|
||||
|
||||
The AI assistant should produce a proposed page layout, components, API bindings, and configuration steps.
|
||||
|
||||
## 6.9 Save and Load
|
||||
|
||||
The system shall allow users to save a project.
|
||||
|
||||
A saved project should include:
|
||||
|
||||
* Pages
|
||||
* Canvas layout
|
||||
* Components
|
||||
* API action definitions
|
||||
* Component bindings
|
||||
* Response mappings
|
||||
* Non-secret configuration
|
||||
|
||||
Secrets should not be stored directly in exported project files unless encrypted or intentionally allowed by policy.
|
||||
|
||||
## 6.10 Preview Mode
|
||||
|
||||
The system shall provide a preview mode.
|
||||
|
||||
Preview mode shall allow users to:
|
||||
|
||||
* Interact with the UI as an end user.
|
||||
* Trigger API calls.
|
||||
* View API responses.
|
||||
* Validate bindings.
|
||||
* Test component-to-component updates.
|
||||
|
||||
## 6.11 Export
|
||||
|
||||
The system should support exporting the project definition as JSON.
|
||||
|
||||
Future export options may include:
|
||||
|
||||
* React application export
|
||||
* Static frontend bundle
|
||||
* Documentation export
|
||||
* Markdown summary
|
||||
* Deployment package
|
||||
|
||||
## 6.12 Project Editors
|
||||
|
||||
Conductor shall support multiple methods for creating and editing a project.
|
||||
|
||||
### Visual Editor
|
||||
|
||||
The Visual Editor shall be the primary user interface for creating applications.
|
||||
|
||||
The Visual Editor shall provide:
|
||||
|
||||
* Drag-and-drop placement of UI components
|
||||
* Component selection
|
||||
* Component movement and resizing
|
||||
* Property editing
|
||||
* Event configuration
|
||||
* Component binding configuration
|
||||
* Visual indication of selected components
|
||||
* Canvas zoom and pan
|
||||
* Undo and redo operations (future enhancement)
|
||||
|
||||
Changes made through the Visual Editor shall immediately update the project's canonical JSON definition.
|
||||
|
||||
### JSON Editor
|
||||
|
||||
Conductor shall provide a JSON Editor for advanced users.
|
||||
|
||||
The JSON Editor shall allow users to directly edit the project's canonical JSON document.
|
||||
|
||||
The JSON Editor should provide:
|
||||
|
||||
* Syntax highlighting
|
||||
* Automatic formatting
|
||||
* Schema validation
|
||||
* Error reporting
|
||||
* Search and replace
|
||||
* Read-only and editable modes
|
||||
|
||||
Changes made through the JSON Editor shall immediately update the Visual Editor.
|
||||
|
||||
The Visual Editor and JSON Editor shall always represent the same project state.
|
||||
|
||||
### Canonical Project Model
|
||||
|
||||
Both editors shall operate on the same underlying project definition.
|
||||
|
||||
Neither editor shall maintain an independent representation of the application.
|
||||
|
||||
All modifications shall update the project's canonical JSON document, which serves as the authoritative representation of the application.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model
|
||||
|
||||
## 7.1 Project
|
||||
|
||||
A project represents a UI application.
|
||||
|
||||
A project contains:
|
||||
|
||||
* Project ID
|
||||
* Name
|
||||
* Description
|
||||
* Pages
|
||||
* API actions
|
||||
* Global variables
|
||||
* Metadata
|
||||
|
||||
## 7.2 Page
|
||||
|
||||
A page contains:
|
||||
|
||||
* Page ID
|
||||
* Name
|
||||
* Layout
|
||||
* Components
|
||||
* Page-level events
|
||||
|
||||
## 7.3 Component
|
||||
|
||||
A component contains:
|
||||
|
||||
* Component ID
|
||||
* Type
|
||||
* Name
|
||||
* Position
|
||||
* Size
|
||||
* Properties
|
||||
* Events
|
||||
* Bindings
|
||||
|
||||
## 7.4 API Action
|
||||
|
||||
An API action contains:
|
||||
|
||||
* Action ID
|
||||
* Name
|
||||
* Method
|
||||
* URL
|
||||
* Headers
|
||||
* Parameters
|
||||
* Body template
|
||||
* Authentication configuration
|
||||
* Response mapping
|
||||
|
||||
## 7.5 Binding
|
||||
|
||||
A binding defines how data moves between components and actions.
|
||||
|
||||
A binding contains:
|
||||
|
||||
* Source
|
||||
* Target
|
||||
* Trigger event
|
||||
* Transformation rule
|
||||
* Error handling behavior
|
||||
|
||||
## 7.6 Project Definition Document
|
||||
|
||||
The canonical representation of a Conductor project shall be a single structured JSON document.
|
||||
|
||||
This document represents the complete application definition and serves as the authoritative source of truth for the project.
|
||||
|
||||
The project definition shall include:
|
||||
|
||||
* Project metadata
|
||||
* Pages
|
||||
* Canvas layout
|
||||
* UI components
|
||||
* Component properties
|
||||
* Events
|
||||
* REST API action definitions
|
||||
* Component bindings
|
||||
* Global variables
|
||||
* Application settings
|
||||
* Theme information (future)
|
||||
* Version information
|
||||
|
||||
The backend shall persist this project definition as JSON.
|
||||
|
||||
The backend may extract selected metadata into relational database fields for indexing, searching, reporting, or performance optimization, but the JSON project definition remains the canonical representation.
|
||||
|
||||
A project definition should be portable between Conductor installations and suitable for export, import, version control, and AI-assisted modification.
|
||||
|
||||
Example capabilities enabled by this approach include:
|
||||
|
||||
* Exporting a complete application as a single file.
|
||||
* Importing existing applications.
|
||||
* Versioning projects in Git.
|
||||
* Comparing changes between revisions.
|
||||
* AI-assisted editing of complete applications.
|
||||
* Generating documentation from the project definition.
|
||||
* Generating frontend code from the project definition.
|
||||
|
||||
Future versions of Conductor may define a published JSON schema describing the project definition format to support validation, tooling, and interoperability.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 8. Example MVP Workflow
|
||||
|
||||
1. User creates a new project.
|
||||
2. User drags a dropdown onto the canvas.
|
||||
3. User configures the dropdown to call an API endpoint on page load.
|
||||
4. API response populates the dropdown options.
|
||||
5. User drags a text input onto the canvas.
|
||||
6. User drags a button onto the canvas.
|
||||
7. User configures the button to call a POST endpoint.
|
||||
8. The POST body uses values from the dropdown and text input.
|
||||
9. User drags a JSON viewer onto the canvas.
|
||||
10. Button response updates the JSON viewer.
|
||||
11. User tests the flow in preview mode.
|
||||
12. User saves the project.
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Requirements
|
||||
|
||||
## 9.1 Frontend
|
||||
|
||||
Recommended frontend stack:
|
||||
|
||||
* React
|
||||
* TypeScript
|
||||
* Drag-and-drop library
|
||||
* Component state management
|
||||
* JSON schema-driven configuration panels
|
||||
|
||||
## 9.2 Backend
|
||||
|
||||
Recommended backend capabilities:
|
||||
|
||||
* REST API proxy
|
||||
* Project persistence
|
||||
* Secret handling
|
||||
* Authentication configuration storage
|
||||
* API execution logging for troubleshooting
|
||||
* AI assistant integration
|
||||
|
||||
## 9.3 Security
|
||||
|
||||
The system must:
|
||||
|
||||
* Avoid exposing secrets in the frontend.
|
||||
* Store secrets securely.
|
||||
* Mask sensitive values in logs.
|
||||
* Support anonymous API calls where appropriate.
|
||||
* Prevent arbitrary unsafe code execution in user-defined mappings.
|
||||
* Validate URLs and headers before execution.
|
||||
* Consider allowlists for internal endpoint access.
|
||||
|
||||
## 9.4 Observability
|
||||
|
||||
The system should provide basic troubleshooting details:
|
||||
|
||||
* API request timestamp
|
||||
* Method and endpoint
|
||||
* Response status
|
||||
* Response duration
|
||||
* Error message
|
||||
* Sanitized request/response preview
|
||||
|
||||
---
|
||||
|
||||
## 10. MVP Scope
|
||||
|
||||
The MVP should include:
|
||||
|
||||
* Single-page project builder.
|
||||
* Basic canvas.
|
||||
* Core widgets.
|
||||
* REST action configuration.
|
||||
* Anonymous, Basic, Bearer, and API key authentication.
|
||||
* Component-to-API binding.
|
||||
* API response-to-component binding.
|
||||
* Preview mode.
|
||||
* Save/load project as JSON.
|
||||
* IBM Bob/watsonx-assisted generation of project configuration.
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Enhancements
|
||||
|
||||
Potential future enhancements:
|
||||
|
||||
* Multi-page applications.
|
||||
* Role-based access control.
|
||||
* OAuth support.
|
||||
* IBM Cloud IAM integration.
|
||||
* Workflow execution history.
|
||||
* Generated React code export.
|
||||
* Import from OpenAPI specification.
|
||||
* Visual JSON path mapper.
|
||||
* Charts and dashboards.
|
||||
* Reusable templates.
|
||||
* Version history.
|
||||
* Approval workflows.
|
||||
* Deployment to internal hosting.
|
||||
* Git integration.
|
||||
* Team collaboration.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions
|
||||
|
||||
* Should this be a standalone internal tool or embedded into another IBM workflow?
|
||||
* Will API calls execute directly from the browser or through a backend proxy?
|
||||
* How should secrets be stored and managed?
|
||||
* Which Concert/RIA authentication methods are required first?
|
||||
* Should OpenAPI import be part of MVP or future scope?
|
||||
* Is the primary output a working hosted UI, a generated React app, or a reusable project definition?
|
||||
* What level of audit logging is required for internal IBM use?
|
||||
* Should users be allowed to call arbitrary URLs?
|
||||
* Will the tool need approval before calling production endpoints?
|
||||
* Should IBM Bob generate only suggestions, or should it directly modify the canvas?
|
||||
|
||||
---
|
||||
|
||||
## 13. Success Criteria
|
||||
|
||||
The MVP is successful if a user can:
|
||||
|
||||
* Create a simple UI without writing frontend code.
|
||||
* Configure at least one REST API endpoint.
|
||||
* Pass values from UI components into the API request.
|
||||
* Display the API response in another UI component.
|
||||
* Save and reload the project.
|
||||
* Use IBM Bob/watsonx to generate or improve part of the UI/API configuration.
|
||||
* Demonstrate a working Concert/RIA workflow launcher.
|
||||
|
||||
## Web Application Requirement
|
||||
|
||||
The system shall be delivered as a web-based application.
|
||||
|
||||
Users shall access the builder through a browser. No desktop client shall be required.
|
||||
|
||||
The application shall include:
|
||||
|
||||
* A browser-based frontend UI
|
||||
* A backend API service
|
||||
* Persistent project storage
|
||||
* A secure server-side API proxy for calling external REST endpoints
|
||||
|
||||
## Web Server / Hosting Requirement
|
||||
|
||||
The system should not implement its own production-grade web server.
|
||||
|
||||
Instead, it should run behind a standard web server or reverse proxy such as:
|
||||
|
||||
* NGINX
|
||||
* Apache HTTP Server
|
||||
* Caddy
|
||||
* IBM-approved internal hosting infrastructure
|
||||
|
||||
The application backend may include an embedded development server for local testing, but production deployment should use an external reverse proxy.
|
||||
|
||||
The reverse proxy should handle:
|
||||
|
||||
* HTTPS termination
|
||||
* Static frontend asset delivery
|
||||
* Routing requests to the backend service
|
||||
* Request size limits
|
||||
* Basic security headers
|
||||
* Optional access restrictions
|
||||
|
||||
Recommended deployment model:
|
||||
|
||||
```text
|
||||
Browser
|
||||
↓
|
||||
NGINX / Apache / Caddy / IBM-approved reverse proxy
|
||||
↓
|
||||
Frontend static assets + Backend API service
|
||||
↓
|
||||
REST API endpoints / Concert / RIA / other systems
|
||||
```
|
||||
|
||||
The backend service should focus on application logic, project storage, authentication handling, secret management, REST API proxying, and AI integration.
|
||||
|
||||
## Backend Persistence Requirement
|
||||
|
||||
The backend shall include persistent storage for project definitions and runtime metadata.
|
||||
|
||||
For the MVP, SQLite is the preferred database.
|
||||
|
||||
SQLite should store:
|
||||
|
||||
* Projects
|
||||
* Pages
|
||||
* UI components
|
||||
* Component layout data
|
||||
* REST API action definitions
|
||||
* Component-to-component bindings
|
||||
* Component-to-API bindings
|
||||
* Non-secret configuration
|
||||
* Basic execution history
|
||||
* Error/debug logs
|
||||
|
||||
Secrets should not be stored directly in plain text in SQLite.
|
||||
|
||||
The database should be treated as local application state for the MVP, not as an enterprise shared data platform.
|
||||
|
||||
## Recommended MVP Database
|
||||
|
||||
The MVP should use SQLite because it is:
|
||||
|
||||
* Simple to deploy
|
||||
* Easy to back up
|
||||
* Suitable for single-instance usage
|
||||
* Lightweight
|
||||
* Good enough for project metadata and configuration storage
|
||||
* Easier to package with a demo or proof-of-concept
|
||||
|
||||
## Future Database Options
|
||||
|
||||
If the project grows beyond MVP, the backend should be designed so SQLite can later be replaced by a full RDBMS such as PostgreSQL.
|
||||
|
||||
A full RDBMS may be required if the system needs:
|
||||
|
||||
* Multiple concurrent users
|
||||
* Team collaboration
|
||||
* Role-based access control
|
||||
* High availability
|
||||
* Centralized deployment
|
||||
* Enterprise backup/restore
|
||||
* Larger execution history
|
||||
* Reporting or analytics
|
||||
* Strict audit retention
|
||||
303
docs/SCHEMA.md
Normal file
303
docs/SCHEMA.md
Normal file
@ -0,0 +1,303 @@
|
||||
# SCHEMA.md
|
||||
|
||||
# Conductor Project JSON Schema
|
||||
|
||||
This document describes the canonical JSON schema for Conductor project definitions and explains how to use it.
|
||||
|
||||
---
|
||||
|
||||
## Location
|
||||
|
||||
```
|
||||
shared/schemas/conductor-project.schema.json
|
||||
```
|
||||
|
||||
The schema lives in `shared/` so it is accessible to both the backend (Node.js/TypeScript) and any future frontend tooling without duplicating the file.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Every Conductor project is represented as a single structured JSON document — the *project definition*. This document is the authoritative source of truth for a project. All editors (Visual Editor, JSON Editor) read from and write to this document. The backend persists it as-is to SQLite.
|
||||
|
||||
The JSON schema:
|
||||
|
||||
- Documents the exact shape of a valid project definition.
|
||||
- Enables offline validation during development.
|
||||
- Drives IDE autocomplete and inline error highlighting when `$schema` is set in a project file.
|
||||
- Will be used by the backend's validation endpoint (Step 11) to reject malformed saves.
|
||||
- Makes project definitions portable, diffable in Git, and importable/exportable.
|
||||
|
||||
---
|
||||
|
||||
## Schema Version
|
||||
|
||||
The current schema version is **`0.1.0`** (semver).
|
||||
|
||||
Every project definition **must** include a `schemaVersion` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Consumers (backend, editor, preview runtime) must check the `MAJOR` version component. A document with a higher major version than the consumer understands should be rejected with a clear error.
|
||||
|
||||
---
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------|----------|----------|--------------------------------------------------|
|
||||
| `schemaVersion` | `string` | ✅ | Schema version in `MAJOR.MINOR.PATCH` format. |
|
||||
| `project` | `object` | ✅ | Root project object containing all definitions. |
|
||||
|
||||
---
|
||||
|
||||
## `project` Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|----------|----------|----------------------------------------------------------------------|
|
||||
| `id` | `string` | ✅ | Stable unique identifier (UUID or URL-safe slug). Must not change. |
|
||||
| `name` | `string` | ✅ | Human-readable display name (1–200 characters). |
|
||||
| `description` | `string` | — | Optional free-text description. Defaults to `""`. |
|
||||
| `pages` | `array` | ✅ | Ordered list of `Page` objects. May be empty. |
|
||||
| `actions` | `array` | ✅ | Project-level REST action definitions. May be empty. |
|
||||
| `bindings` | `array` | ✅ | Project-level binding definitions. May be empty. |
|
||||
| `variables` | `object` | ✅ | Named global variable declarations. May be empty (`{}`). |
|
||||
| `settings` | `object` | ✅ | Project display and canvas settings. May be empty (`{}`). |
|
||||
|
||||
---
|
||||
|
||||
## Key Sub-Types
|
||||
|
||||
### `Page`
|
||||
|
||||
Represents one view in the project. Required fields: `id`, `name`, `components`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "page_home",
|
||||
"name": "Home",
|
||||
"description": "",
|
||||
"order": 0,
|
||||
"components": [],
|
||||
"events": []
|
||||
}
|
||||
```
|
||||
|
||||
### `Component`
|
||||
|
||||
A UI element placed on a page canvas. Required fields: `id`, `type`, `name`, `position`, `size`.
|
||||
|
||||
Allowed `type` values:
|
||||
`Button` · `TextInput` · `TextArea` · `Dropdown` · `Checkbox` · `RadioGroup` · `Label` · `Table` · `JsonViewer` · `StatusPanel` · `Container`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cmp_launch_btn",
|
||||
"type": "Button",
|
||||
"name": "launchButton",
|
||||
"position": { "x": 24, "y": 284 },
|
||||
"size": { "width": 160, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Launch Workflow",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_trigger_workflow",
|
||||
"inputMap": { "workflowId": "components.workflowIdInput.value" }
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
}
|
||||
```
|
||||
|
||||
### `Action` (REST Action)
|
||||
|
||||
A REST API call definition. Required fields: `id`, `name`, `method`, `url`, `authenticationType`.
|
||||
|
||||
Allowed `method` values: `GET` · `POST` · `PUT` · `PATCH` · `DELETE`
|
||||
|
||||
Allowed `authenticationType` values: `anonymous` · `bearerToken` · `basicAuth` · `apiKeyHeader` · `apiKeyQueryParameter`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "action_trigger_workflow",
|
||||
"name": "Trigger Workflow",
|
||||
"description": "Calls the Concert RIA API to trigger a workflow run.",
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/workflows/{{workflowId}}/run",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"environment": "{{environment}}"
|
||||
},
|
||||
"pathParameters": {
|
||||
"workflowId": "{{workflowId}}"
|
||||
},
|
||||
"bodyTemplate": "{\"params\": {{params}}}",
|
||||
"authenticationType": "bearerToken",
|
||||
"responseMapping": [
|
||||
{ "source": "data.status", "target": "variables.lastStatus" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Security note:** Never store actual secret values (passwords, tokens, API keys) in the project definition. The `authenticationType` field declares the authentication strategy only; the backend resolves credentials from server-side secrets at execution time.
|
||||
|
||||
#### REST Action Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------------------|----------|----------|-----------------------------------------------------------------------------|
|
||||
| `id` | `string` | ✅ | Unique identifier within the project. |
|
||||
| `name` | `string` | ✅ | Human-readable action name shown in the Actions panel. |
|
||||
| `description` | `string` | — | Optional description of what this action does. |
|
||||
| `method` | `string` | ✅ | HTTP method: `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. |
|
||||
| `url` | `string` | ✅ | Target URL. Use `{{paramName}}` for path parameter placeholders. |
|
||||
| `headers` | `object` | — | Static request headers. Values may use `{{variableName}}` syntax. |
|
||||
| `queryParameters` | `object` | — | URL query string parameters. Values may use `{{variableName}}` syntax. |
|
||||
| `pathParameters` | `object` | — | Path segment substitutions. Keys match `{{paramName}}` in the URL. |
|
||||
| `bodyTemplate` | `string` | — | Request body template. Use `{{variableName}}` for runtime substitutions. |
|
||||
| `authenticationType` | `string` | ✅ | Authentication strategy (see allowed values above). Credentials are never stored here. |
|
||||
| `responseMapping` | `array` | — | Rules mapping response fields to component properties or variables. |
|
||||
|
||||
### `Binding`
|
||||
|
||||
Declares data flow between a source and a target. Required fields: `id`, `source`, `target`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "bind_status_panel",
|
||||
"source": "variables.lastRunStatus",
|
||||
"target": "components.statusPanel.message",
|
||||
"trigger": "onChange"
|
||||
}
|
||||
```
|
||||
|
||||
### `Variable`
|
||||
|
||||
A named global variable. Required field: `type`.
|
||||
|
||||
Allowed `type` values: `string` · `number` · `boolean` · `object` · `array`
|
||||
|
||||
```json
|
||||
{
|
||||
"lastRunStatus": {
|
||||
"type": "string",
|
||||
"defaultValue": "",
|
||||
"description": "Status returned by the most recent workflow trigger."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `ProjectSettings`
|
||||
|
||||
Optional canvas and display configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "system",
|
||||
"defaultPageId": "page_home",
|
||||
"canvasWidth": 1280,
|
||||
"canvasHeight": 900
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Files
|
||||
|
||||
| File | Description |
|
||||
|-----------------------------------------------------------------------|-----------------------------------------------------------|
|
||||
| `examples/project-definitions/valid-minimal.json` | Smallest valid project definition (one empty page). |
|
||||
| `examples/project-definitions/valid-full.json` | Full example: Concert Workflow Launcher with all features. |
|
||||
| `examples/project-definitions/valid-rest-actions.json` | Showcases all five authentication types across seven REST actions. No canvas components — validates the action model in isolation. |
|
||||
| `examples/project-definitions/invalid-missing-required.json` | Intentionally invalid document showing schema errors. |
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
### Using `ajv-cli` (recommended)
|
||||
|
||||
Install once globally or run via `npx`:
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
npm install -g ajv-cli ajv-formats
|
||||
|
||||
# Validate a valid document — should print: valid
|
||||
ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d examples/project-definitions/valid-minimal.json \
|
||||
--spec=draft2020
|
||||
|
||||
# Validate the full example
|
||||
ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d examples/project-definitions/valid-full.json \
|
||||
--spec=draft2020
|
||||
|
||||
# Validate the REST actions showcase (all five auth types)
|
||||
ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d examples/project-definitions/valid-rest-actions.json \
|
||||
--spec=draft2020
|
||||
|
||||
# Validate the intentionally invalid document — should print validation errors
|
||||
ajv validate \
|
||||
-s shared/schemas/conductor-project.schema.json \
|
||||
-d examples/project-definitions/invalid-missing-required.json \
|
||||
--spec=draft2020
|
||||
```
|
||||
|
||||
### Using VS Code
|
||||
|
||||
1. Open any example `.json` file.
|
||||
2. The `"$schema"` field at the top of the file points to the schema.
|
||||
3. VS Code will underline validation errors inline and provide autocomplete.
|
||||
|
||||
### In the Backend (future — Step 11)
|
||||
|
||||
The backend will use [`ajv`](https://ajv.js.org/) at runtime to validate project definitions on save:
|
||||
|
||||
```ts
|
||||
import Ajv from 'ajv';
|
||||
import schema from '../../shared/schemas/conductor-project.schema.json';
|
||||
|
||||
const ajv = new Ajv({ strict: true });
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
function validateProjectJson(doc: unknown): string[] {
|
||||
const valid = validate(doc);
|
||||
if (valid) return [];
|
||||
return (validate.errors ?? []).map(e => `${e.instancePath} ${e.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
> This is documented here for reference. The validation endpoint itself is not part of Step 7.
|
||||
|
||||
---
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
| Change type | Version bump |
|
||||
|----------------------------------------------------|--------------|
|
||||
| Add optional field | MINOR |
|
||||
| Add required field or remove existing field | MAJOR |
|
||||
| Change allowed enum values | MAJOR |
|
||||
| Clarify description with no structural change | PATCH |
|
||||
|
||||
---
|
||||
|
||||
## Schema Stability
|
||||
|
||||
The schema is currently at **`0.x`** (pre-stable). Breaking changes may occur between minor versions until `1.0.0` is declared.
|
||||
401
docs/response-mapping-model.md
Normal file
401
docs/response-mapping-model.md
Normal file
@ -0,0 +1,401 @@
|
||||
# Response Mapping Model
|
||||
|
||||
**Step 17.0 design document — established 2025**
|
||||
|
||||
This document records the canonical decisions about how action responses flow into
|
||||
component state in the Conductor Preview runtime. It is the authoritative
|
||||
reference for Steps 17.x implementation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Why `project.bindings` is canonical](#1-why-projectbindings-is-canonical)
|
||||
2. [Why actions own runtime responses](#2-why-actions-own-runtime-responses)
|
||||
3. [Why `actions.<id>.response` is a virtual namespace](#3-why-actionsidresponse-is-a-virtual-namespace)
|
||||
4. [Source path grammar](#4-source-path-grammar)
|
||||
5. [Target path grammar](#5-target-path-grammar)
|
||||
6. [Trigger semantics](#6-trigger-semantics)
|
||||
7. [Label runtime-value behaviour](#7-label-runtime-value-behaviour)
|
||||
8. [Compatibility — existing `onClick` bindings](#8-compatibility--existing-onclick-bindings)
|
||||
9. [Deprecation of `action.responseMapping`](#9-deprecation-of-actionresponsemapping)
|
||||
10. [Step 17.1 scope](#10-step-171-scope)
|
||||
11. [Deferred capabilities](#11-deferred-capabilities)
|
||||
12. [Required diagnostics](#12-required-diagnostics)
|
||||
|
||||
---
|
||||
|
||||
## 1. Why `project.bindings` is canonical
|
||||
|
||||
Two mechanisms could in principle move action response data into components:
|
||||
|
||||
| Mechanism | Location | Status |
|
||||
|---|---|---|
|
||||
| `action.responseMapping[]` | Inside each `Action` object | **Deprecated** |
|
||||
| `project.bindings[]` | Top-level project array | **Canonical** |
|
||||
|
||||
`project.bindings` was chosen as the sole canonical mechanism for the following
|
||||
reasons:
|
||||
|
||||
**Single locus of data-flow declarations.** All data movement in a Conductor
|
||||
project — between components, from action responses, from variables — is
|
||||
expressed in one flat array. Keeping response routing there avoids a split
|
||||
where "inbound response routing" lives inside actions while all other data
|
||||
movement lives at the project level.
|
||||
|
||||
**Symmetry with component-sourced bindings.** A binding from a TextInput value
|
||||
to a Label uses the same structure as a binding from an action response to a
|
||||
JsonViewer. Same schema, same Inspector rendering, same future transform field.
|
||||
|
||||
**Decoupled from action definition.** An action definition describes *how to
|
||||
call* an endpoint (URL, method, auth). Where the response goes is a layout
|
||||
concern, not an API concern. Separating them means a single action can feed
|
||||
multiple targets without duplicating the action.
|
||||
|
||||
**Better Inspector and diagnostics surface.** A flat array of bindings is easy
|
||||
to enumerate, validate cross-references for, and display in a panel.
|
||||
Distributing response mapping rules across individual action objects makes
|
||||
completeness checks harder.
|
||||
|
||||
**Incremental migration path.** Existing documents that carry
|
||||
`action.responseMapping` values remain valid under the schema; they are simply
|
||||
ignored at runtime. New documents use only `project.bindings`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why actions own runtime responses
|
||||
|
||||
Although `project.actions` is a design-time array of REST action definitions,
|
||||
the Preview runtime must store ephemeral per-action state: the most recent
|
||||
response, loading flag, and error. This state is:
|
||||
|
||||
- **Not stored in canonical project JSON.** The project document is the
|
||||
design-time source of truth. Mutating it with runtime values would corrupt
|
||||
the save/load round-trip.
|
||||
- **Keyed by action ID.** Even though actions are stored as an ordered array
|
||||
in the project document, the runtime resolves them by `id` for O(1) lookup.
|
||||
- **Discarded on navigation or refresh.** Preview state is ephemeral React
|
||||
state (`useState`).
|
||||
|
||||
The proposed runtime shape (to be implemented in Step 17.1) is:
|
||||
|
||||
```ts
|
||||
type ActionRuntimeState = {
|
||||
response?: ProxyResponse; // the full ProxyResponse envelope
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ActionRuntimeStateMap = Record<string, ActionRuntimeState>;
|
||||
```
|
||||
|
||||
This is a parallel structure to the existing `componentState` map in
|
||||
`usePreviewRuntime`. In Step 17.1 it will be introduced alongside it.
|
||||
|
||||
> **Step 17.0 note:** This runtime shape is defined here for design purposes.
|
||||
> It is not implemented in Step 17.0. The current runtime continues to store
|
||||
> the full `ProxyResponse` envelope directly in `componentState[id].value`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why `actions.<id>.response` is a virtual namespace
|
||||
|
||||
In binding source and target expressions, paths of the form:
|
||||
|
||||
```
|
||||
actions.<actionId>.response
|
||||
actions.<actionId>.response.body
|
||||
actions.<actionId>.response.body.<field>
|
||||
```
|
||||
|
||||
refer to runtime action response data. This is a **virtual namespace** because:
|
||||
|
||||
1. `project.actions` is an **array** in the canonical document, not an object
|
||||
keyed by ID. At design time, `actions.action_httpbin` does not exist as a
|
||||
JSON key path.
|
||||
|
||||
2. The runtime resolves `<actionId>` via `Array.find((a) => a.id === actionId)`,
|
||||
not by property access.
|
||||
|
||||
3. The path is only meaningful during Preview execution. It has no value in the
|
||||
persisted project document.
|
||||
|
||||
The path syntax is chosen for readability and symmetry with `components.<name>.*`
|
||||
target paths. It is not JSONPath; it is a **Conductor runtime dot-path** with
|
||||
a fixed `actions.<id>.response[.body[.<fields>]]` prefix.
|
||||
|
||||
---
|
||||
|
||||
## 4. Source path grammar
|
||||
|
||||
Step 17.1 will support the following source path forms for action-response
|
||||
bindings:
|
||||
|
||||
```
|
||||
actions.<actionId>.response
|
||||
actions.<actionId>.response.body
|
||||
actions.<actionId>.response.body.<field>
|
||||
actions.<actionId>.response.body.<nested>.<field>
|
||||
```
|
||||
|
||||
### Resolution rules
|
||||
|
||||
| Path | Resolved value |
|
||||
|---|---|
|
||||
| `actions.<id>.response` | The full `ProxyResponse` envelope `{ ok, status, statusText, headers, body, durationMs }` |
|
||||
| `actions.<id>.response.body` | `ProxyResponse.body` — parsed JSON object or text string |
|
||||
| `actions.<id>.response.body.<field>` | `ProxyResponse.body.<field>` where `body` is an object |
|
||||
| `actions.<id>.response.body.<nested>.<field>` | Deep dot-path traversal on `body` |
|
||||
|
||||
If a field in the path does not exist on the response body, the resolved value
|
||||
is `undefined`. Step 17.1 will surface a runtime warning in that case; it
|
||||
will not throw.
|
||||
|
||||
### Deferred source path forms
|
||||
|
||||
The following are explicitly deferred and must not be inferred from the above:
|
||||
|
||||
- `actions.<id>.response.headers.<header>` — reserved; not in Step 17.1
|
||||
- `actions.<id>.response.status` — reserved; not in Step 17.1
|
||||
- Wildcard `*` segments
|
||||
- Filter expressions `[?(...)]`
|
||||
- Array index syntax `[0]`, `[-1]`
|
||||
- Keys containing literal `.` characters
|
||||
|
||||
---
|
||||
|
||||
## 5. Target path grammar
|
||||
|
||||
Step 17.1 will support only:
|
||||
|
||||
```
|
||||
components.<componentName>.value
|
||||
```
|
||||
|
||||
### Semantic enforcement
|
||||
|
||||
- `<componentName>` must match `component.name` (not `component.id`) in the
|
||||
project's page components, consistent with the existing binding model.
|
||||
- The property segment **must be** `.value`. The Inspector must emit a warning
|
||||
for targets with any other property segment (e.g. `.data`, `.message`,
|
||||
`.label`). The runtime must not silently remap these to `.value`.
|
||||
|
||||
### Property segment rationale
|
||||
|
||||
`.value` is the single runtime-mutable property exposed by `ComponentRuntimeState`
|
||||
for display purposes. Until per-property runtime targeting is designed:
|
||||
|
||||
- `components.<name>.value` is valid.
|
||||
- `components.<name>.data`, `.message`, `.label`, `.text`, etc. are unsupported.
|
||||
They must produce an Inspector diagnostic and a runtime warning.
|
||||
|
||||
### Deferred target forms
|
||||
|
||||
- `variables.<variableName>` — variable runtime support is deferred
|
||||
- `components.<name>.label` — Label runtime-value override is deferred to Step 17.1
|
||||
(see §7)
|
||||
- Per-property routing for non-`.value` segments
|
||||
|
||||
---
|
||||
|
||||
## 6. Trigger semantics
|
||||
|
||||
### Canonical trigger for action-response bindings: `onSuccess`
|
||||
|
||||
```json
|
||||
{ "trigger": "onSuccess" }
|
||||
```
|
||||
|
||||
`onSuccess` fires when the action completes and `ProxyResponse.ok === true`.
|
||||
|
||||
This is the trigger all new action-response bindings should use.
|
||||
|
||||
### Why not `onChange` (the schema default)?
|
||||
|
||||
The schema's `binding.trigger` default is `"onChange"`. This default is
|
||||
appropriate for component-to-component data flow (e.g. a TextInput value driving
|
||||
a downstream Label). It is not appropriate for action-response bindings because:
|
||||
|
||||
- Action responses are not "change" events on a component; they are completion
|
||||
events on an async operation.
|
||||
- `"onChange"` implies reactive/continuous behaviour; action execution is
|
||||
discrete and user-triggered (or lifecycle-triggered).
|
||||
|
||||
The `"onChange"` default is therefore **not the correct default for
|
||||
action-response bindings**. The Inspector should surface a warning when
|
||||
`"onChange"` is found on a binding whose source is `actions.*`.
|
||||
|
||||
### Deferred triggers
|
||||
|
||||
| Trigger | Status |
|
||||
|---|---|
|
||||
| `onSuccess` | **Step 17.1** |
|
||||
| `onError` | Deferred |
|
||||
| `onLoad` | Deferred |
|
||||
| `onChange` | Component-change bindings only; deferred |
|
||||
| Chained actions | Deferred |
|
||||
|
||||
---
|
||||
|
||||
## 7. Label runtime-value behaviour
|
||||
|
||||
**Current (Step 17.0):** Label renders `properties.label` only. It has no
|
||||
`runtimeState` wiring. Runtime action responses cannot update a Label.
|
||||
|
||||
**Planned (Step 17.1):**
|
||||
|
||||
- `properties.label` — design-time / configured text. Displayed when no
|
||||
runtime value is set.
|
||||
- `componentState[id].value` — runtime display value. If set (not `undefined`),
|
||||
it overrides `properties.label` in the Preview renderer.
|
||||
- A binding of the form:
|
||||
```json
|
||||
{
|
||||
"source": "actions.<id>.response.body.<field>",
|
||||
"target": "components.<labelName>.value",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
```
|
||||
writes the resolved field value (coerced to string for display) into
|
||||
`componentState[labelId].value`.
|
||||
- **Runtime mutations must not modify canonical project JSON.** The override
|
||||
is ephemeral; it lives only in `useState` for the Preview session.
|
||||
|
||||
This makes Label consistent with JsonViewer, which already reads
|
||||
`runtimeState.value` in `PreviewComponent.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Compatibility — existing `onClick` bindings
|
||||
|
||||
Steps 15 and 16 produced examples and docs using:
|
||||
|
||||
```json
|
||||
{ "trigger": "onClick" }
|
||||
```
|
||||
|
||||
on action-response bindings. These bindings fire when the button's `onClick`
|
||||
event causes the action to execute.
|
||||
|
||||
**Step 17.0 treatment:** `"onClick"` bindings continue to work exactly as they
|
||||
do today. The runtime (`usePreviewRuntime`) fires all bindings whose source
|
||||
matches the action ID, regardless of their `trigger` value.
|
||||
|
||||
**Recommended migration:** New bindings should use `"onSuccess"` rather than
|
||||
`"onClick"`. The Inspector may surface an info-level diagnostic recommending
|
||||
migration on bindings that use `"onClick"` as the trigger on an
|
||||
`actions.*` source. This is not yet implemented.
|
||||
|
||||
**Important:** `"onClick"` on an action-response binding is **legacy**, not
|
||||
invalid. Documents using it will not fail schema validation and will not be
|
||||
broken by Step 17.1.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deprecation of `action.responseMapping`
|
||||
|
||||
`action.responseMapping` is **deprecated as of Step 17.0**.
|
||||
|
||||
| Property | Status |
|
||||
|---|---|
|
||||
| Present in schema | Yes — retained for backward compatibility |
|
||||
| `x-deprecated: true` annotation | Added in Step 17.0 |
|
||||
| Schema `description` | Updated to say `project.bindings` is canonical |
|
||||
| TypeScript `@deprecated` JSDoc | Added in Step 17.0 |
|
||||
| TS type narrowed | Yes — changed from `unknown[]` to `ResponseMappingRule[]` |
|
||||
| Executed by Preview runtime | **No** — never was, never will be |
|
||||
| Executed by backend proxy | **No** — never was, never will be |
|
||||
| Shown by Inspector | **No** |
|
||||
| Removal timeline | Not scheduled; retained for backward compatibility only |
|
||||
|
||||
**No new examples should use `action.responseMapping`.** Existing examples
|
||||
(`valid-full.json`) that carry it are not updated; they remain valid because
|
||||
the field is optional and the schema accepts (but ignores) its contents.
|
||||
|
||||
---
|
||||
|
||||
## 10. Step 17.1 scope
|
||||
|
||||
Step 17.1 will implement execution of `project.bindings` for
|
||||
action-response-to-component data movement. Specifically:
|
||||
|
||||
1. **`ActionRuntimeStateMap`** — introduce alongside the existing
|
||||
`componentState` map in `usePreviewRuntime`. After each proxy call,
|
||||
write the `ProxyResponse` into `actionState[actionId].response`.
|
||||
|
||||
2. **Source path resolution** — implement dot-path traversal for
|
||||
`actions.<id>.response`, `.response.body`, and `.response.body.<field...>`.
|
||||
|
||||
3. **Binding execution loop** — after a successful proxy call, iterate
|
||||
`project.bindings` whose `trigger` is `"onSuccess"` (or `"onClick"` for
|
||||
legacy compatibility) and whose `source` parses to the completed action ID.
|
||||
Resolve the source path against `actionState[actionId].response`. Write the
|
||||
resolved value to `componentState[targetId].value`.
|
||||
|
||||
4. **Label renderer update** — make `LabelRenderer` read `runtimeState?.value`
|
||||
as an override over `properties.label`, consistent with `JsonViewerRenderer`.
|
||||
|
||||
5. **Inspector diagnostics** — add warnings for:
|
||||
- Unsupported target property segments (anything other than `.value`)
|
||||
- `"onChange"` trigger on action-response bindings (recommend `"onSuccess"`)
|
||||
- Bindings whose source action ID does not exist
|
||||
- Bindings whose target component name does not exist
|
||||
|
||||
6. **`valid-response-mapping-basic.json`** — the example created in Step 17.0
|
||||
becomes the primary Step 17.1 integration test document.
|
||||
|
||||
---
|
||||
|
||||
## 11. Deferred capabilities
|
||||
|
||||
The following are explicitly out of scope until separately designed and scheduled:
|
||||
|
||||
| Capability | Reason deferred |
|
||||
|---|---|
|
||||
| `onError` trigger | Requires error-state routing design |
|
||||
| `onLoad` trigger | Requires page lifecycle event system |
|
||||
| Component-change data movement (`onChange`) | Requires reactive binding engine |
|
||||
| Chained actions | Requires action dependency graph |
|
||||
| Variable runtime support | Requires variable state store |
|
||||
| Variable template interpolation in binding paths | Depends on variable runtime |
|
||||
| Wildcard `*` in source paths | JSONPath-style engine not scoped |
|
||||
| Filter expressions in source paths | Same |
|
||||
| Array-index syntax `[0]` | Same |
|
||||
| Keys with literal `.` in name | Requires path quoting syntax |
|
||||
| `components.<name>.label` as writable target | Deferred to Step 17.1 label work |
|
||||
| Any target property other than `.value` | Deferred to per-property routing design |
|
||||
| Dropdown or Table runtime population | Separate step |
|
||||
| `ComponentType` / `FullComponentType` cleanup | Separate step |
|
||||
| Component-level `bindings[]` array | Schema field exists but is not wired |
|
||||
| Duplicate component-name detection at save time | Future validation step |
|
||||
| Transform expression execution | Deferred (`binding.transform` field retained) |
|
||||
| Authentication credential injection | Separate step |
|
||||
| `action.responseMapping` execution | Permanently deferred; field deprecated |
|
||||
|
||||
---
|
||||
|
||||
## 12. Required diagnostics
|
||||
|
||||
The following diagnostics must be implemented by Step 17.1. They are listed
|
||||
here so the Inspector and runtime can be built consistently against this spec.
|
||||
|
||||
### Inspector (static, design-time)
|
||||
|
||||
| Condition | Severity | Message guidance |
|
||||
|---|---|---|
|
||||
| Binding `source` references action ID that does not exist in `project.actions` | Warning | "Source references action `<id>` which does not exist." |
|
||||
| Binding `target` references component name that does not exist on any page | Warning | "Target references component `<name>` which does not exist." |
|
||||
| Binding `target` uses property segment other than `.value` | Warning | "Target property `<segment>` is not supported. Use `.value`." |
|
||||
| Two or more components share the same `name` on a page | Warning | "Duplicate component name `<name>`. Binding resolution is ambiguous." |
|
||||
| `trigger: "onChange"` on an `actions.*` source binding | Info | "Consider using `onSuccess` for action-response bindings." |
|
||||
| `trigger: "onClick"` on an `actions.*` source binding | Info | "Legacy trigger. Consider migrating to `onSuccess`." |
|
||||
| `action.responseMapping` is non-empty | Info | "Use `project.bindings` instead. `responseMapping` is deprecated and not executed." |
|
||||
|
||||
### Runtime (execution-time, Preview only)
|
||||
|
||||
| Condition | Behaviour |
|
||||
|---|---|
|
||||
| Source path resolves to `undefined` (field not on body) | Write `undefined` to target; emit console warning |
|
||||
| Target component not found at runtime | Skip write; emit console warning |
|
||||
| `ProxyResponse.ok === false` and trigger is `onSuccess` | Do not fire binding; optionally fire `onError` bindings (deferred) |
|
||||
| Action ID in binding source does not match any executing action | Skip silently |
|
||||
132
examples/mock-server/conductor-mock-server.js
Normal file
132
examples/mock-server/conductor-mock-server.js
Normal file
@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* conductor-mock-server.js
|
||||
*
|
||||
* A minimal HTTP mock server for local Conductor development and validation.
|
||||
* It echoes request bodies through routes that mimic the public HTTPBin API,
|
||||
* so Conductor project examples can run fully offline or from within Docker.
|
||||
*
|
||||
* Usage:
|
||||
* node examples/mock-server/conductor-mock-server.js
|
||||
*
|
||||
* Listens on:
|
||||
* http://localhost:8787 (host access)
|
||||
* http://host.docker.internal:8787 (Docker container access on Mac/Win)
|
||||
*
|
||||
* Routes:
|
||||
* POST /anything — parse JSON body, return { json: <body>, method, url, headers }
|
||||
* GET /get — return { args: <query params>, method, url, headers }
|
||||
* * — 404 JSON error
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const url = require('url');
|
||||
|
||||
const PORT = 8787;
|
||||
const HOST = '0.0.0.0';
|
||||
|
||||
// ── Request-body reader ───────────────────────────────────────────────────────
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Response helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
const payload = JSON.stringify(body, null, 2);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
// ── Route handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleAnything(req, res) {
|
||||
// Parse body (if any)
|
||||
let parsedJson = null;
|
||||
const rawBody = await readBody(req);
|
||||
|
||||
if (rawBody.trim()) {
|
||||
try {
|
||||
parsedJson = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
sendJson(res, 400, {
|
||||
error: 'invalid_json',
|
||||
message: `Request body is not valid JSON: ${e.message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = url.parse(req.url, true);
|
||||
|
||||
sendJson(res, 200, {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
args: parsed.query,
|
||||
headers: req.headers,
|
||||
json: parsedJson,
|
||||
data: rawBody || '',
|
||||
});
|
||||
}
|
||||
|
||||
function handleGet(req, res) {
|
||||
const parsed = url.parse(req.url, true);
|
||||
sendJson(res, 200, {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
args: parsed.query,
|
||||
headers: req.headers,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Server ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// Strip query string for routing
|
||||
const pathname = url.parse(req.url).pathname;
|
||||
|
||||
// OPTIONS preflight (CORS)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pathname === '/anything') {
|
||||
await handleAnything(req, res);
|
||||
} else if (pathname === '/get' && req.method === 'GET') {
|
||||
handleGet(req, res);
|
||||
} else {
|
||||
sendJson(res, 404, {
|
||||
error: 'not_found',
|
||||
message: `No mock route for ${req.method} ${pathname}. ` +
|
||||
`Available: POST /anything, GET /get`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
sendJson(res, 500, {
|
||||
error: 'internal_error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Conductor mock server listening on http://${HOST}:${PORT}`);
|
||||
console.log(' POST /anything — echoes JSON body as { json: <body> }');
|
||||
console.log(' GET /get — echoes query parameters as { args: <params> }');
|
||||
console.log('Press Ctrl+C to stop.');
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
{
|
||||
"_comment": "INVALID DOCUMENT — intentionally fails schema validation. Do not use as a template.",
|
||||
"_errors": [
|
||||
"project.pages[0].components[0].properties.options[0]: missing required property 'value'",
|
||||
"project.pages[0].components[0].properties.options[1]: 'label' must be a string (got integer)"
|
||||
],
|
||||
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_invalid_dropdown_option",
|
||||
"name": "Invalid Dropdown Option Shape",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"components": [
|
||||
{
|
||||
"id": "dropdown_bad",
|
||||
"type": "Dropdown",
|
||||
"name": "badDropdown",
|
||||
"position": { "x": 40, "y": 40 },
|
||||
"size": { "width": 260, "height": 72 },
|
||||
"properties": {
|
||||
"label": "Broken Dropdown",
|
||||
"placeholder": "Select",
|
||||
"options": [
|
||||
{ "label": "Only label, no value" },
|
||||
{ "label": 42, "value": "prod" }
|
||||
],
|
||||
"value": "",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [],
|
||||
"bindings": [],
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
49
examples/project-definitions/invalid-missing-required.json
Normal file
49
examples/project-definitions/invalid-missing-required.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"_comment": "INVALID DOCUMENT — intentionally fails schema validation. Do not use as a template.",
|
||||
"_errors": [
|
||||
"Missing required top-level field: 'schemaVersion'",
|
||||
"project.id is missing (required)",
|
||||
"project.pages is missing (required)",
|
||||
"project.actions is missing (required)",
|
||||
"project.bindings is missing (required)",
|
||||
"project.variables is missing (required)",
|
||||
"project.settings is missing (required)",
|
||||
"project.pages[0].components is missing (required)",
|
||||
"project.pages[0].components[0].position is missing (required)",
|
||||
"project.pages[0].components[0].size is missing (required)",
|
||||
"project.pages[0].components[0].type uses a value not in the allowed enum",
|
||||
"project.actions[0].method uses a value not in the allowed enum",
|
||||
"project.actions[0].url is missing (required)",
|
||||
"project.actions[0].authenticationType is missing (required)"
|
||||
],
|
||||
|
||||
"project": {
|
||||
"name": "Broken Project",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_1",
|
||||
"name": "Page One",
|
||||
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_bad",
|
||||
"type": "UnknownWidget",
|
||||
"name": "badWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_bad",
|
||||
"name": "Bad Action",
|
||||
"method": "FETCH"
|
||||
}
|
||||
],
|
||||
"bindings": [],
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
104
examples/project-definitions/invalid-template-malformed.json
Normal file
104
examples/project-definitions/invalid-template-malformed.json
Normal file
@ -0,0 +1,104 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_invalid_template_malformed",
|
||||
"name": "Broken Template — Malformed Syntax",
|
||||
"description": "Step 16.5 diagnostic example: the REST action contains a malformed template {{components.hostnameInput.value (missing closing braces). The Actions & Bindings inspector should warn. Executing the action in Preview should fail cleanly.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "input_hostname",
|
||||
"type": "TextInput",
|
||||
"name": "hostnameInput",
|
||||
"position": { "x": 40, "y": 40 },
|
||||
"size": { "width": 320, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Hostname",
|
||||
"placeholder": "e.g. test-host-123",
|
||||
"defaultValue": "",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "btn_run",
|
||||
"type": "Button",
|
||||
"name": "runButton",
|
||||
"position": { "x": 40, "y": 100 },
|
||||
"size": { "width": 160, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Fetch Data",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_httpbin"
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "viewer_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 40, "y": 160 },
|
||||
"size": { "width": 560, "height": 300 },
|
||||
"properties": {
|
||||
"label": "Response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "binding_action_to_viewer" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_httpbin",
|
||||
"name": "Httpbin GET (malformed template)",
|
||||
"description": "Contains a malformed template: {{components.hostnameInput.value — missing closing }}.",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"hostname": "{{components.hostnameInput.value",
|
||||
"source": "conductor-step-16-5-malformed"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "binding_action_to_viewer",
|
||||
"source": "actions.action_httpbin.response",
|
||||
"target": "components.resultsViewer.value",
|
||||
"trigger": "onClick"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_invalid_template_missing",
|
||||
"name": "Broken Template — Missing Component",
|
||||
"description": "Step 16.5 diagnostic example: the REST action references {{components.missingInput.value}} but no component named missingInput exists. The Actions & Bindings inspector should warn. Executing the action in Preview should fail cleanly.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "btn_run",
|
||||
"type": "Button",
|
||||
"name": "runButton",
|
||||
"position": { "x": 40, "y": 40 },
|
||||
"size": { "width": 160, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Fetch Data",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_httpbin"
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "viewer_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 40, "y": 100 },
|
||||
"size": { "width": 560, "height": 300 },
|
||||
"properties": {
|
||||
"label": "Response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "binding_action_to_viewer" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_httpbin",
|
||||
"name": "Httpbin GET (broken template)",
|
||||
"description": "References {{components.missingInput.value}} — the component missingInput does not exist in this project.",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"hostname": "{{components.missingInput.value}}",
|
||||
"source": "conductor-step-16-5-broken"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "binding_action_to_viewer",
|
||||
"source": "actions.action_httpbin.response",
|
||||
"target": "components.resultsViewer.value",
|
||||
"trigger": "onClick"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
87
examples/project-definitions/valid-button-binding.json
Normal file
87
examples/project-definitions/valid-button-binding.json
Normal file
@ -0,0 +1,87 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_button_binding_demo",
|
||||
"name": "Button-to-REST Demo",
|
||||
"description": "Step 15 example: one Button, one JsonViewer, one anonymous REST action, and one binding. Clicking the button calls the backend proxy and displays the response in the JSON Viewer.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "btn_run",
|
||||
"type": "Button",
|
||||
"name": "runButton",
|
||||
"position": { "x": 40, "y": 40 },
|
||||
"size": { "width": 160, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Fetch Data",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_httpbin"
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "viewer_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 40, "y": 104 },
|
||||
"size": { "width": 560, "height": 300 },
|
||||
"properties": {
|
||||
"label": "Response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "binding_action_to_viewer" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_httpbin",
|
||||
"name": "Httpbin GET",
|
||||
"description": "Anonymous GET to httpbin.org — returns a JSON echo of the request for testing.",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"source": "conductor"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "binding_action_to_viewer",
|
||||
"source": "actions.action_httpbin.response",
|
||||
"target": "components.resultsViewer.value",
|
||||
"trigger": "onClick"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
120
examples/project-definitions/valid-dropdown-basic.json
Normal file
120
examples/project-definitions/valid-dropdown-basic.json
Normal file
@ -0,0 +1,120 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_dropdown_basic",
|
||||
"name": "Dropdown Basic Demo",
|
||||
"description": "Step 17.2 example: a Dropdown whose selected value feeds a REST action via {{components.environmentDropdown.value}} template interpolation. The response is displayed in a JsonViewer.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "lbl_env",
|
||||
"type": "Label",
|
||||
"name": "environmentLabel",
|
||||
"position": { "x": 40, "y": 20 },
|
||||
"size": { "width": 320, "height": 28 },
|
||||
"properties": {
|
||||
"label": "Select an environment and click Fetch Data.",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
{
|
||||
"id": "dropdown_env",
|
||||
"type": "Dropdown",
|
||||
"name": "environmentDropdown",
|
||||
"position": { "x": 40, "y": 56 },
|
||||
"size": { "width": 260, "height": 72 },
|
||||
"properties": {
|
||||
"label": "Environment",
|
||||
"placeholder": "Select an environment",
|
||||
"options": [
|
||||
{ "label": "Development", "value": "dev" },
|
||||
{ "label": "Test", "value": "test" },
|
||||
{ "label": "Production", "value": "prod" }
|
||||
],
|
||||
"value": "dev",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
{
|
||||
"id": "btn_fetch",
|
||||
"type": "Button",
|
||||
"name": "fetchButton",
|
||||
"position": { "x": 40, "y": 144 },
|
||||
"size": { "width": 160, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Fetch Data",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_httpbin_env"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "viewer_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 40, "y": 200 },
|
||||
"size": { "width": 560, "height": 300 },
|
||||
"properties": {
|
||||
"label": "Response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "bind_body_to_viewer" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_httpbin_env",
|
||||
"name": "Httpbin GET with Environment",
|
||||
"description": "GET to httpbin.org with the selected environment as a query parameter, interpolated from {{components.environmentDropdown.value}}.",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"env": "{{components.environmentDropdown.value}}",
|
||||
"source": "conductor-step-17-2"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "bind_body_to_viewer",
|
||||
"source": "actions.action_httpbin_env.response.body",
|
||||
"target": "components.resultsViewer.value",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_dropdown_response_mapping",
|
||||
"name": "Dropdown Response Mapping Demo",
|
||||
"description": "Step 17.3 example: a REST action populates a Dropdown's options at runtime via an onSuccess binding. The button POSTs to httpbin.org/anything which echoes the body; the binding maps response.body.json.options to hostDropdown.options.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "lbl_title",
|
||||
"type": "Label",
|
||||
"name": "titleLabel",
|
||||
"position": { "x": 40, "y": 16 },
|
||||
"size": { "width": 480, "height": 28 },
|
||||
"properties": {
|
||||
"label": "Dropdown Response Mapping Demo — Step 17.3",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "dropdown_host",
|
||||
"type": "Dropdown",
|
||||
"name": "hostDropdown",
|
||||
"position": { "x": 40, "y": 56 },
|
||||
"size": { "width": 280, "height": 72 },
|
||||
"properties": {
|
||||
"label": "Host",
|
||||
"placeholder": "Select a host",
|
||||
"options": [
|
||||
{ "label": "(Fallback) Local", "value": "local" }
|
||||
],
|
||||
"value": "",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "btn_load",
|
||||
"type": "Button",
|
||||
"name": "loadOptionsButton",
|
||||
"position": { "x": 40, "y": 144 },
|
||||
"size": { "width": 180, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Load Options",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_load_hosts"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": "btn_use",
|
||||
"type": "Button",
|
||||
"name": "useSelectionButton",
|
||||
"position": { "x": 240, "y": 144 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Fetch Selected Host",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_use_selection"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": "viewer_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 40, "y": 200 },
|
||||
"size": { "width": 580, "height": 300 },
|
||||
"properties": {
|
||||
"label": "Response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_load_hosts",
|
||||
"name": "Load Host Options",
|
||||
"description": "POST to httpbin.org/anything with a body containing options. HTTPBin echoes the parsed JSON body at response.body.json. The onSuccess binding maps .json.options to hostDropdown.options.",
|
||||
"method": "POST",
|
||||
"url": "https://httpbin.org/anything",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "{\"options\":[{\"label\":\"Host One\",\"value\":\"host1\"},{\"label\":\"Host Two\",\"value\":\"host2\"},{\"label\":\"Host Three\",\"value\":\"host3\"}]}",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
},
|
||||
{
|
||||
"id": "action_use_selection",
|
||||
"name": "Fetch Selected Host",
|
||||
"description": "GET to httpbin.org/get passing the currently selected host as a query parameter. Template: {{components.hostDropdown.value}}.",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"host": "{{components.hostDropdown.value}}"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "mapping_hosts_to_dropdown",
|
||||
"source": "actions.action_load_hosts.response.body.json.options",
|
||||
"target": "components.hostDropdown.options",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "bind_response_to_viewer",
|
||||
"source": "actions.action_use_selection.response.body",
|
||||
"target": "components.resultsViewer.value",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
267
examples/project-definitions/valid-full.json
Normal file
267
examples/project-definitions/valid-full.json
Normal file
@ -0,0 +1,267 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_concert_workflow_launcher",
|
||||
"name": "Concert Workflow Launcher",
|
||||
"description": "A UI for triggering IBM Concert RIA workflows, monitoring status, and displaying structured results.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_launch",
|
||||
"name": "Launch Workflow",
|
||||
"description": "Primary page for selecting and triggering workflows.",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_env_dropdown",
|
||||
"type": "Dropdown",
|
||||
"name": "environmentDropdown",
|
||||
"position": { "x": 24, "y": 24 },
|
||||
"size": { "width": 320, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Target Environment",
|
||||
"placeholder": "Select environment…",
|
||||
"visible": true,
|
||||
"disabled": false,
|
||||
"required": true,
|
||||
"options": [
|
||||
{ "label": "Development", "value": "dev" },
|
||||
{ "label": "Staging", "value": "stg" },
|
||||
{ "label": "Production", "value": "prod" }
|
||||
]
|
||||
},
|
||||
"events": [],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "cmp_workflow_input",
|
||||
"type": "TextInput",
|
||||
"name": "workflowIdInput",
|
||||
"position": { "x": 24, "y": 84 },
|
||||
"size": { "width": 320, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Workflow ID",
|
||||
"placeholder": "e.g. wf-deploy-app",
|
||||
"defaultValue": "",
|
||||
"visible": true,
|
||||
"disabled": false,
|
||||
"required": true
|
||||
},
|
||||
"events": [],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "cmp_params_textarea",
|
||||
"type": "TextArea",
|
||||
"name": "paramsTextArea",
|
||||
"position": { "x": 24, "y": 144 },
|
||||
"size": { "width": 320, "height": 120 },
|
||||
"properties": {
|
||||
"label": "Parameters (JSON)",
|
||||
"placeholder": "{ \"version\": \"1.2.3\" }",
|
||||
"defaultValue": "{}",
|
||||
"visible": true,
|
||||
"disabled": false,
|
||||
"required": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "cmp_launch_btn",
|
||||
"type": "Button",
|
||||
"name": "launchButton",
|
||||
"position": { "x": 24, "y": 284 },
|
||||
"size": { "width": 160, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Launch Workflow",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_trigger_workflow",
|
||||
"inputMap": {
|
||||
"environment": "components.environmentDropdown.value",
|
||||
"workflowId": "components.workflowIdInput.value",
|
||||
"params": "components.paramsTextArea.value"
|
||||
}
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "cmp_status_panel",
|
||||
"type": "StatusPanel",
|
||||
"name": "statusPanel",
|
||||
"position": { "x": 24, "y": 344 },
|
||||
"size": { "width": 640, "height": 60 },
|
||||
"properties": {
|
||||
"label": "",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "bind_status_to_panel" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_results_viewer",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 24, "y": 420 },
|
||||
"size": { "width": 640, "height": 320 },
|
||||
"properties": {
|
||||
"label": "Response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "bind_response_to_viewer" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
},
|
||||
{
|
||||
"id": "page_history",
|
||||
"name": "Execution History",
|
||||
"description": "Read-only view of recent workflow executions.",
|
||||
"order": 1,
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_history_table",
|
||||
"type": "Table",
|
||||
"name": "historyTable",
|
||||
"position": { "x": 24, "y": 24 },
|
||||
"size": { "width": 900, "height": 480 },
|
||||
"properties": {
|
||||
"label": "Recent Executions",
|
||||
"visible": true,
|
||||
"disabled": false,
|
||||
"columns": [
|
||||
{ "key": "id", "header": "Execution ID" },
|
||||
{ "key": "workflow", "header": "Workflow" },
|
||||
{ "key": "environment", "header": "Environment" },
|
||||
{ "key": "status", "header": "Status", "width": 100 },
|
||||
{ "key": "startedAt", "header": "Started At" }
|
||||
]
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "bind_history_to_table" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"event": "onLoad",
|
||||
"actionId": "action_list_executions",
|
||||
"inputMap": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_trigger_workflow",
|
||||
"name": "Trigger Workflow",
|
||||
"description": "Calls the Concert RIA API to trigger a workflow run.",
|
||||
"method": "POST",
|
||||
"url": "https://concert.example.com/api/v1/workflows/{{workflowId}}/run",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"environment": "{{environment}}"
|
||||
},
|
||||
"pathParameters": {
|
||||
"workflowId": "{{workflowId}}"
|
||||
},
|
||||
"bodyTemplate": "{\"params\": {{params}}}",
|
||||
"authenticationType": "bearerToken",
|
||||
"responseMapping": [
|
||||
{
|
||||
"source": "status",
|
||||
"target": "variables.lastRunStatus"
|
||||
},
|
||||
{
|
||||
"source": "data",
|
||||
"target": "components.resultsViewer.data"
|
||||
},
|
||||
{
|
||||
"source": "message",
|
||||
"target": "components.statusPanel.message"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "action_list_executions",
|
||||
"name": "List Executions",
|
||||
"description": "Fetches recent workflow execution records.",
|
||||
"method": "GET",
|
||||
"url": "https://concert.example.com/api/v1/executions",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "bearerToken",
|
||||
"responseMapping": [
|
||||
{
|
||||
"source": "executions",
|
||||
"target": "components.historyTable.data"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "bind_status_to_panel",
|
||||
"source": "variables.lastRunStatus",
|
||||
"target": "components.statusPanel.message",
|
||||
"trigger": "onChange"
|
||||
},
|
||||
{
|
||||
"id": "bind_response_to_viewer",
|
||||
"source": "actions.action_trigger_workflow.response",
|
||||
"target": "components.resultsViewer.data",
|
||||
"trigger": "onChange"
|
||||
},
|
||||
{
|
||||
"id": "bind_history_to_table",
|
||||
"source": "actions.action_list_executions.response",
|
||||
"target": "components.historyTable.data",
|
||||
"trigger": "onChange"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {
|
||||
"lastRunStatus": {
|
||||
"type": "string",
|
||||
"defaultValue": "",
|
||||
"description": "Status string returned by the most recent workflow trigger."
|
||||
},
|
||||
"selectedEnvironment": {
|
||||
"type": "string",
|
||||
"defaultValue": "dev",
|
||||
"description": "Currently selected target environment."
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"theme": "system",
|
||||
"defaultPageId": "page_launch",
|
||||
"canvasWidth": 1280,
|
||||
"canvasHeight": 900
|
||||
}
|
||||
}
|
||||
}
|
||||
104
examples/project-definitions/valid-input-request-binding.json
Normal file
104
examples/project-definitions/valid-input-request-binding.json
Normal file
@ -0,0 +1,104 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_input_request_binding",
|
||||
"name": "Input-to-Request Binding Demo",
|
||||
"description": "Step 16 example: a Text Input feeds a query parameter into a REST action via {{components.hostnameInput.value}} template interpolation. The response is displayed in a JsonViewer.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "input_hostname",
|
||||
"type": "TextInput",
|
||||
"name": "hostnameInput",
|
||||
"position": { "x": 40, "y": 40 },
|
||||
"size": { "width": 320, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Hostname",
|
||||
"placeholder": "e.g. test-host-123",
|
||||
"defaultValue": "",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "btn_run",
|
||||
"type": "Button",
|
||||
"name": "runButton",
|
||||
"position": { "x": 40, "y": 100 },
|
||||
"size": { "width": 160, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Fetch Data",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_httpbin"
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "viewer_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 40, "y": 160 },
|
||||
"size": { "width": 560, "height": 300 },
|
||||
"properties": {
|
||||
"label": "Response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": [
|
||||
{ "bindingId": "binding_action_to_viewer" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_httpbin",
|
||||
"name": "Httpbin GET with Input",
|
||||
"description": "GET to httpbin.org with a query parameter interpolated from the hostnameInput Text Input.",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"hostname": "{{components.hostnameInput.value}}",
|
||||
"source": "conductor-step-16"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "binding_action_to_viewer",
|
||||
"source": "actions.action_httpbin.response",
|
||||
"target": "components.resultsViewer.value",
|
||||
"trigger": "onClick"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
20
examples/project-definitions/valid-minimal.json
Normal file
20
examples/project-definitions/valid-minimal.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_minimal_example",
|
||||
"name": "Minimal Example Project",
|
||||
"description": "The smallest valid Conductor project definition. Contains one empty page and no actions, bindings, or variables.",
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_home",
|
||||
"name": "Home",
|
||||
"components": []
|
||||
}
|
||||
],
|
||||
"actions": [],
|
||||
"bindings": [],
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
105
examples/project-definitions/valid-response-mapping-basic.json
Normal file
105
examples/project-definitions/valid-response-mapping-basic.json
Normal file
@ -0,0 +1,105 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_response_mapping_basic",
|
||||
"name": "Response Mapping Basic Demo",
|
||||
"description": "Step 17.0 example: demonstrates the canonical project.bindings response-mapping model. One Button triggers a GET action. An onSuccess binding routes the full response body to a JsonViewer. A second onSuccess binding routes a specific field (origin) to a Label. Neither binding executes yet — runtime support is added in Step 17.1.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "btn_fetch",
|
||||
"type": "Button",
|
||||
"name": "fetchButton",
|
||||
"position": { "x": 40, "y": 40 },
|
||||
"size": { "width": 160, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Fetch Data",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_httpbin"
|
||||
}
|
||||
],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "lbl_origin",
|
||||
"type": "Label",
|
||||
"name": "originLabel",
|
||||
"position": { "x": 40, "y": 100 },
|
||||
"size": { "width": 320, "height": 32 },
|
||||
"properties": {
|
||||
"label": "Origin: (click Fetch Data to populate)",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": []
|
||||
},
|
||||
{
|
||||
"id": "viewer_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "resultsViewer",
|
||||
"position": { "x": 40, "y": 152 },
|
||||
"size": { "width": 560, "height": 320 },
|
||||
"properties": {
|
||||
"label": "Response Body",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [],
|
||||
"bindings": []
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_httpbin",
|
||||
"name": "Httpbin GET",
|
||||
"description": "Anonymous GET to httpbin.org/get. Returns a stable JSON object whose fields (origin, url, args, headers) are used by the Step 17.1 runtime binding test.",
|
||||
"method": "GET",
|
||||
"url": "https://httpbin.org/get",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"source": "conductor-step-17"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "bind_body_to_viewer",
|
||||
"source": "actions.action_httpbin.response.body",
|
||||
"target": "components.resultsViewer.value",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "bind_origin_to_label",
|
||||
"source": "actions.action_httpbin.response.body.origin",
|
||||
"target": "components.originLabel.value",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
155
examples/project-definitions/valid-rest-actions.json
Normal file
155
examples/project-definitions/valid-rest-actions.json
Normal file
@ -0,0 +1,155 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_rest_actions_showcase",
|
||||
"name": "REST Actions Showcase",
|
||||
"description": "Demonstrates all five MVP authentication types and common REST action patterns. This project has no canvas components — it exists purely to validate the REST action model.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_home",
|
||||
"name": "Home",
|
||||
"components": []
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
|
||||
{
|
||||
"id": "action_anonymous_get",
|
||||
"name": "Fetch Public Data",
|
||||
"description": "Anonymous GET — no credentials required. Fetches a public JSON resource.",
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/public/items",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"limit": "20",
|
||||
"offset": "0"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous",
|
||||
"responseMapping": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "action_bearer_post",
|
||||
"name": "Create Item (Bearer Token)",
|
||||
"description": "Authenticated POST using a Bearer token. The backend resolves the token from a named secret at execution time.",
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/items",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "{\"name\": \"{{itemName}}\", \"category\": \"{{category}}\"}",
|
||||
"authenticationType": "bearerToken",
|
||||
"responseMapping": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "action_basic_auth_get",
|
||||
"name": "Fetch Protected Resource (Basic Auth)",
|
||||
"description": "GET using HTTP Basic authentication. Username and password are resolved from a server-side secret — never stored in this document.",
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/protected/report",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"format": "json"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "basicAuth",
|
||||
"responseMapping": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "action_api_key_header_get",
|
||||
"name": "Fetch via API Key Header",
|
||||
"description": "GET authenticated by passing an API key in a request header (e.g. X-API-Key). The key value is resolved server-side.",
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/data/{{datasetId}}",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"page": "{{page}}"
|
||||
},
|
||||
"pathParameters": {
|
||||
"datasetId": "{{datasetId}}"
|
||||
},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "apiKeyHeader",
|
||||
"responseMapping": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "action_api_key_query_get",
|
||||
"name": "Fetch via API Key Query Parameter",
|
||||
"description": "GET authenticated by appending an API key as a query parameter. The key value is resolved server-side.",
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/weather",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {
|
||||
"city": "{{city}}",
|
||||
"units": "metric"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "apiKeyQueryParameter",
|
||||
"responseMapping": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "action_patch_item",
|
||||
"name": "Update Item (Bearer Token)",
|
||||
"description": "PATCH an existing item by path parameter ID.",
|
||||
"method": "PATCH",
|
||||
"url": "https://api.example.com/items/{{itemId}}",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {},
|
||||
"pathParameters": {
|
||||
"itemId": "{{itemId}}"
|
||||
},
|
||||
"bodyTemplate": "{\"status\": \"{{newStatus}}\"}",
|
||||
"authenticationType": "bearerToken",
|
||||
"responseMapping": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "action_delete_item",
|
||||
"name": "Delete Item (Bearer Token)",
|
||||
"description": "DELETE an item by path parameter ID.",
|
||||
"method": "DELETE",
|
||||
"url": "https://api.example.com/items/{{itemId}}",
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
},
|
||||
"queryParameters": {},
|
||||
"pathParameters": {
|
||||
"itemId": "{{itemId}}"
|
||||
},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "bearerToken",
|
||||
"responseMapping": []
|
||||
}
|
||||
|
||||
],
|
||||
|
||||
"bindings": [],
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
68
examples/project-definitions/valid-table-basic.json
Normal file
68
examples/project-definitions/valid-table-basic.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"project": {
|
||||
"id": "table-basic-example",
|
||||
"name": "Table Basic Example",
|
||||
"description": "Step 17.4: Basic Table component with static rows and explicit columns.",
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_1",
|
||||
"name": "Main",
|
||||
"components": [
|
||||
{
|
||||
"id": "label_1",
|
||||
"type": "Label",
|
||||
"name": "label_1",
|
||||
"position": { "x": 32, "y": 16 },
|
||||
"size": { "width": 320, "height": 32 },
|
||||
"properties": {
|
||||
"label": "Server Inventory",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
{
|
||||
"id": "inventoryTable",
|
||||
"type": "Table",
|
||||
"name": "inventoryTable",
|
||||
"position": { "x": 32, "y": 64 },
|
||||
"size": { "width": 520, "height": 260 },
|
||||
"properties": {
|
||||
"label": "Table",
|
||||
"columns": [
|
||||
{ "key": "hostname", "header": "Hostname" },
|
||||
{ "key": "environment", "header": "Environment" },
|
||||
{ "key": "status", "header": "Status" }
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"hostname": "host-one",
|
||||
"environment": "development",
|
||||
"status": "online"
|
||||
},
|
||||
{
|
||||
"hostname": "host-two",
|
||||
"environment": "test",
|
||||
"status": "offline"
|
||||
},
|
||||
{
|
||||
"hostname": "host-three",
|
||||
"environment": "production",
|
||||
"status": "online"
|
||||
}
|
||||
],
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [],
|
||||
"bindings": [],
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
124
examples/project-definitions/valid-table-response-mapping.json
Normal file
124
examples/project-definitions/valid-table-response-mapping.json
Normal file
@ -0,0 +1,124 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_table_response_mapping",
|
||||
"name": "Table Response Mapping Demo",
|
||||
"description": "Step 17.5 example: a REST action populates a Table's rows at runtime via an onSuccess binding. The button POSTs to the local mock server (http://host.docker.internal:8787/anything) which echoes the body; the binding maps response.body.json.items to inventoryTable.rows. Start the mock server with: node examples/mock-server/conductor-mock-server.js",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "lbl_title",
|
||||
"type": "Label",
|
||||
"name": "titleLabel",
|
||||
"position": { "x": 40, "y": 16 },
|
||||
"size": { "width": 560, "height": 28 },
|
||||
"properties": {
|
||||
"label": "Table Response Mapping Demo — Step 17.5",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "btn_load",
|
||||
"type": "Button",
|
||||
"name": "loadButton",
|
||||
"position": { "x": 40, "y": 60 },
|
||||
"size": { "width": 160, "height": 40 },
|
||||
"properties": {
|
||||
"label": "Load Inventory",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "onClick",
|
||||
"actionId": "action_get_inventory"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": "tbl_inventory",
|
||||
"type": "Table",
|
||||
"name": "inventoryTable",
|
||||
"position": { "x": 40, "y": 120 },
|
||||
"size": { "width": 560, "height": 220 },
|
||||
"properties": {
|
||||
"label": "Inventory",
|
||||
"columns": [
|
||||
{ "key": "hostname", "header": "Hostname" },
|
||||
{ "key": "environment", "header": "Environment" },
|
||||
{ "key": "status", "header": "Status" }
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"hostname": "fallback-host",
|
||||
"environment": "local",
|
||||
"status": "configured"
|
||||
}
|
||||
],
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": "viewer_response",
|
||||
"type": "JsonViewer",
|
||||
"name": "responseViewer",
|
||||
"position": { "x": 40, "y": 360 },
|
||||
"size": { "width": 560, "height": 200 },
|
||||
"properties": {
|
||||
"label": "",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_get_inventory",
|
||||
"name": "Get Inventory",
|
||||
"description": "POSTs to the local Conductor mock server. The echoed response body contains json.items which is mapped to inventoryTable.rows. Start the mock server with: node examples/mock-server/conductor-mock-server.js",
|
||||
"method": "POST",
|
||||
"url": "http://host.docker.internal:8787/anything",
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyTemplate": "{\"items\":[{\"hostname\":\"host-one\",\"environment\":\"development\",\"status\":\"online\"},{\"hostname\":\"host-two\",\"environment\":\"test\",\"status\":\"offline\"},{\"hostname\":\"host-three\",\"environment\":\"production\",\"status\":\"online\"}]}",
|
||||
"authenticationType": "anonymous"
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "binding_inventory_rows",
|
||||
"source": "actions.action_get_inventory.response.body.json.items",
|
||||
"target": "components.inventoryTable.rows",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "binding_inventory_response",
|
||||
"source": "actions.action_get_inventory.response.body",
|
||||
"target": "components.responseViewer.value",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {},
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
137
examples/project-definitions/valid-variable-binding.json
Normal file
137
examples/project-definitions/valid-variable-binding.json
Normal file
@ -0,0 +1,137 @@
|
||||
{
|
||||
"$schema": "../../shared/schemas/conductor-project.schema.json",
|
||||
"schemaVersion": "0.1.0",
|
||||
"project": {
|
||||
"id": "proj_variable_binding_demo",
|
||||
"name": "Variable Binding Demo",
|
||||
"description": "Demonstrates cross-action data flow via a runtime variable. Step 1 fetches a value and stores it in a variable. Step 2 uses that variable in its request template.",
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"id": "page_main",
|
||||
"name": "Main Page",
|
||||
"order": 0,
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_btn_step1",
|
||||
"type": "Button",
|
||||
"name": "btnFetch",
|
||||
"position": { "x": 24, "y": 24 },
|
||||
"size": { "width": 180, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Step 1 — Fetch value",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{ "event": "onClick", "actionId": "action_step1" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_btn_step2",
|
||||
"type": "Button",
|
||||
"name": "btnUseVariable",
|
||||
"position": { "x": 216, "y": 24 },
|
||||
"size": { "width": 220, "height": 44 },
|
||||
"properties": {
|
||||
"label": "Step 2 — Use variable",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": [
|
||||
{ "event": "onClick", "actionId": "action_step2" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_step1_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "step1Result",
|
||||
"position": { "x": 24, "y": 84 },
|
||||
"size": { "width": 580, "height": 160 },
|
||||
"properties": {
|
||||
"label": "Step 1 raw response",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
},
|
||||
{
|
||||
"id": "cmp_step2_result",
|
||||
"type": "JsonViewer",
|
||||
"name": "step2Result",
|
||||
"position": { "x": 24, "y": 260 },
|
||||
"size": { "width": 580, "height": 160 },
|
||||
"properties": {
|
||||
"label": "Step 2 response (used variable in request)",
|
||||
"visible": true,
|
||||
"disabled": false
|
||||
},
|
||||
"events": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"id": "action_step1",
|
||||
"name": "Step 1 — GET /get",
|
||||
"description": "Fetches a value from the mock server via GET /get. Sends a fixed query parameter 'value=captured-from-step-1'. The response body.args.value field is stored in the runtime variable capturedId.",
|
||||
"method": "GET",
|
||||
"url": "http://host.docker.internal:8787/get",
|
||||
"headers": { "Accept": "application/json" },
|
||||
"queryParameters": {
|
||||
"value": "captured-from-step-1"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous"
|
||||
},
|
||||
{
|
||||
"id": "action_step2",
|
||||
"name": "Step 2 — GET /get using variable",
|
||||
"description": "Calls GET /get with capturedId as the 'id' query parameter. Click Step 1 first to populate the variable. The mock server echoes the query parameters back under response.body.args.",
|
||||
"method": "GET",
|
||||
"url": "http://host.docker.internal:8787/get",
|
||||
"headers": { "Accept": "application/json" },
|
||||
"queryParameters": {
|
||||
"id": "{{variables.capturedId}}"
|
||||
},
|
||||
"pathParameters": {},
|
||||
"bodyTemplate": "",
|
||||
"authenticationType": "anonymous"
|
||||
}
|
||||
],
|
||||
|
||||
"bindings": [
|
||||
{
|
||||
"id": "binding_step1_to_viewer",
|
||||
"source": "actions.action_step1.response.body",
|
||||
"target": "components.step1Result.value",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "binding_step1_to_variable",
|
||||
"source": "actions.action_step1.response.body.args.value",
|
||||
"target": "variables.capturedId",
|
||||
"trigger": "onSuccess"
|
||||
},
|
||||
{
|
||||
"id": "binding_step2_to_viewer",
|
||||
"source": "actions.action_step2.response.body",
|
||||
"target": "components.step2Result.value",
|
||||
"trigger": "onSuccess"
|
||||
}
|
||||
],
|
||||
|
||||
"variables": {
|
||||
"capturedId": {
|
||||
"type": "string",
|
||||
"defaultValue": "",
|
||||
"description": "Populated by Step 1's response binding. Used as the 'id' query parameter in Step 2's request template {{variables.capturedId}}."
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
build
|
||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# CI=false keeps the CRA dev server running (no TTY in Docker = CI-like environment)
|
||||
ENV CI=false
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
17273
frontend/package-lock.json
generated
Normal file
17273
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "conductor-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"dev": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
frontend/public/index.html
Normal file
12
frontend/public/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Conductor</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
51
frontend/src/App.tsx
Normal file
51
frontend/src/App.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React, { useState } from 'react';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import WelcomePanel from './components/WelcomePanel';
|
||||
import VisualEditor from './components/VisualEditor/VisualEditor';
|
||||
import JsonEditor from './components/JsonEditor/JsonEditor';
|
||||
import Preview from './components/Preview/Preview';
|
||||
import ActionInspector from './components/ActionInspector/ActionInspector';
|
||||
import { ProjectProvider } from './context/ProjectContext';
|
||||
|
||||
// Placeholder panels — replaced with real implementations in later steps
|
||||
function PlaceholderPanel({ title, description }: { title: string; description: string }): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>{title}</h2>
|
||||
<p style={{ color: '#57606a', fontSize: 14 }}>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent(): React.ReactElement {
|
||||
const [activeItem, setActiveItem] = useState<string>('');
|
||||
|
||||
function renderContent(): React.ReactElement | null {
|
||||
if (!activeItem) return <WelcomePanel />;
|
||||
if (activeItem === 'visual-editor') return <VisualEditor />;
|
||||
if (activeItem === 'projects') return (
|
||||
<PlaceholderPanel title="Projects" description="Project list — coming in a later step." />
|
||||
);
|
||||
if (activeItem === 'json-editor') return <JsonEditor />;
|
||||
if (activeItem === 'preview') return <Preview />;
|
||||
if (activeItem === 'inspector') return <ActionInspector />;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout activeItem={activeItem} onNavigate={setActiveItem}>
|
||||
{renderContent()}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ProjectProvider wraps the entire app so all editor views share one context.
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
<ProjectProvider>
|
||||
<AppContent />
|
||||
</ProjectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
83
frontend/src/api/projectsApi.ts
Normal file
83
frontend/src/api/projectsApi.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { ApiProjectRow, ProjectDocument } from '../types/project';
|
||||
|
||||
// ── Base URL ──────────────────────────────────────────────────────────────────
|
||||
// CRA proxy forwards /api/* to backend:4000 in Docker; in local dev the
|
||||
// package.json proxy field handles it. We always use a relative path.
|
||||
|
||||
const BASE = '/api/projects';
|
||||
|
||||
// ── Response helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const body = await res.json() as { error?: string };
|
||||
if (body.error) message = body.error;
|
||||
} catch {
|
||||
// ignore parse failure — use status text
|
||||
message = res.statusText || message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── API functions ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** List all projects (summary rows — project_json is included but may be large). */
|
||||
export async function listProjects(): Promise<ApiProjectRow[]> {
|
||||
const res = await fetch(BASE);
|
||||
return handleResponse<ApiProjectRow[]>(res);
|
||||
}
|
||||
|
||||
/** Fetch a single project by numeric ID. */
|
||||
export async function getProject(id: number): Promise<ApiProjectRow> {
|
||||
const res = await fetch(`${BASE}/${id}`);
|
||||
return handleResponse<ApiProjectRow>(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project.
|
||||
* Sends the canonical project JSON as the `project_json` field.
|
||||
* The backend stores `name` and `description` as top-level columns for
|
||||
* listing/search, and the full JSON as `project_json`.
|
||||
*/
|
||||
export async function createProject(
|
||||
name: string,
|
||||
description: string,
|
||||
doc: ProjectDocument,
|
||||
): Promise<ApiProjectRow> {
|
||||
const res = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
project_json: JSON.stringify(doc),
|
||||
}),
|
||||
});
|
||||
return handleResponse<ApiProjectRow>(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (update) an existing project.
|
||||
* Always sends the full canonical project JSON so the backend stays in sync.
|
||||
*/
|
||||
export async function saveProject(
|
||||
id: number,
|
||||
name: string,
|
||||
description: string,
|
||||
doc: ProjectDocument,
|
||||
): Promise<ApiProjectRow> {
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
project_json: JSON.stringify(doc),
|
||||
}),
|
||||
});
|
||||
return handleResponse<ApiProjectRow>(res);
|
||||
}
|
||||
51
frontend/src/api/proxyApi.ts
Normal file
51
frontend/src/api/proxyApi.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Client for the backend REST proxy.
|
||||
*
|
||||
* POST /api/proxy/execute accepts a REST action definition and executes it
|
||||
* server-side, returning a structured response envelope regardless of the
|
||||
* upstream HTTP status.
|
||||
*/
|
||||
|
||||
import type { RestAction } from '../types/project';
|
||||
|
||||
// ── Response types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProxyResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
// ── API call ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sends a REST action definition to the backend proxy for execution.
|
||||
*
|
||||
* Returns a ProxyResponse on success (including upstream non-2xx).
|
||||
* Throws an Error with a human-readable message on network failure or a
|
||||
* non-200 response from the proxy itself (e.g. 400 validation error).
|
||||
*/
|
||||
export async function executeAction(action: RestAction): Promise<ProxyResponse> {
|
||||
const res = await fetch('/api/proxy/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(action),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// The proxy itself rejected the request (400/502) — extract the error message
|
||||
let message = `Proxy error: HTTP ${res.status}`;
|
||||
try {
|
||||
const body = await res.json() as { error?: string };
|
||||
if (body.error) message = body.error;
|
||||
} catch {
|
||||
// ignore parse failure
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json() as Promise<ProxyResponse>;
|
||||
}
|
||||
@ -0,0 +1,449 @@
|
||||
/* ── Page layout ──────────────────────────────────────────────────── */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
/* ── Page header ──────────────────────────────────────────────────── */
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 13px;
|
||||
color: #57606a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Section ──────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: #f7f8fa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.sectionCount {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
background: #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────────── */
|
||||
|
||||
.empty {
|
||||
padding: 20px 16px;
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* ── Action card ──────────────────────────────────────────────────── */
|
||||
|
||||
.actionCard {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionCard:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.actionCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.methodBadge {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.methodGet { background: #dcfce7; color: #15803d; }
|
||||
.methodPost { background: #dbeafe; color: #1d4ed8; }
|
||||
.methodPut { background: #fef9c3; color: #854d0e; }
|
||||
.methodPatch { background: #fce7f3; color: #9d174d; }
|
||||
.methodDelete { background: #fee2e2; color: #b91c1c; }
|
||||
.methodOther { background: #e5e7eb; color: #374151; }
|
||||
|
||||
.actionName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.actionId {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: #8b949e;
|
||||
background: #f0f2f5;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionUrl {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: #57606a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actionMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.authBadge {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.actionDescription {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Test Action button and result ────────────────────────────────── */
|
||||
|
||||
.testRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.testBtn {
|
||||
padding: 4px 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.testBtn:hover:not(:disabled) {
|
||||
background: #f7f8fa;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.testBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.testResult {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.testResultPre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
color: #1f2328;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.testResultError {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.testStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #57606a;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Binding card ─────────────────────────────────────────────────── */
|
||||
|
||||
.bindingCard {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bindingCard:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bindingCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bindingId {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.triggerBadge {
|
||||
font-size: 11px;
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bindingFlow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bindingRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bindingLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bindingExpr {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.bindingResolved {
|
||||
font-size: 11px;
|
||||
color: #15803d;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.bindingUnresolved {
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.bindingActionRef {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Diagnostics ──────────────────────────────────────────────────── */
|
||||
|
||||
.diagList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Warning — something is missing or misconfigured */
|
||||
.diagWarn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.diagWarnIcon {
|
||||
flex-shrink: 0;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Info — action is unused / no obvious trigger */
|
||||
.diagInfo {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.diagInfoIcon {
|
||||
flex-shrink: 0;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Summary banner at the top of a section when warnings exist */
|
||||
.diagSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border-bottom: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.diagSummaryOk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
color: #15803d;
|
||||
background: #f0fdf4;
|
||||
border-bottom: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
/* ── Template references section (Step 16.5) ─────────────────────────── */
|
||||
|
||||
.templateSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.templateSectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.templateRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.templateRaw {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: #1f2328;
|
||||
background: #eaeef2;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.templateLocation {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.templateResolved {
|
||||
font-size: 11px;
|
||||
color: #15803d;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.templateUnresolved {
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
921
frontend/src/components/ActionInspector/ActionInspector.tsx
Normal file
921
frontend/src/components/ActionInspector/ActionInspector.tsx
Normal file
@ -0,0 +1,921 @@
|
||||
/**
|
||||
* Action and Binding Inspector — Step 15.5 / 15.6 / 16.5 / 17.1 / 17.3 / 17.4 / 17.5 / 18.1
|
||||
*
|
||||
* Read-only view of REST actions and bindings from the current project,
|
||||
* read directly from ProjectContext.
|
||||
*
|
||||
* ── Diagnostics computed ──────────────────────────────────────────────────────
|
||||
* Per action (Step 15.6):
|
||||
* • Action is not triggered by any component event (info)
|
||||
* Per action (Step 16.5 / 18.1):
|
||||
* • Template references a component that does not exist (warning)
|
||||
* • Template uses unsupported property (not "value") (warning)
|
||||
* • Template references a variable that does not exist (warning) ← 18.1
|
||||
* • Malformed template syntax (unclosed {{) (warning)
|
||||
*
|
||||
* Per binding (Step 15.6 + 17.1 + 18.1):
|
||||
* • Source references an action that does not exist (warning)
|
||||
* • Source path grammar is not a supported action-response path (warning)
|
||||
* • Target references a component that does not exist (warning)
|
||||
* • Target component name is duplicated on a page (warning)
|
||||
* • Target property not supported for the component type (warning)
|
||||
* (.value → JsonViewer/Label; .options → Dropdown; .rows → Table)
|
||||
* • Target is a variable that is not declared (warning) ← 18.1
|
||||
* • Trigger is unsupported for variable-target bindings (warning) ← 18.1
|
||||
* • No component has an event that fires the source action (warning)
|
||||
* • Trigger is unsupported for response bindings (warning)
|
||||
* • Trigger "onClick" is legacy — recommend "onSuccess" (info)
|
||||
*
|
||||
* Per component event (surfaced inside binding diagnostics):
|
||||
* • Event actionId references a missing action (warning)
|
||||
*
|
||||
* Per Table component (Step 17.4):
|
||||
* • Duplicate column keys (warning)
|
||||
* • Empty column key (warning)
|
||||
* • Binding targeting Table.rows / Table.value (warning)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import { executeAction } from '../../api/proxyApi';
|
||||
import type { ProxyResponse } from '../../api/proxyApi';
|
||||
import type { RestAction, Binding, CanvasComponent, DropdownOption, TableColumn, Variable } from '../../types/project';
|
||||
import { extractTemplates, classifyVariableExpression } from '../Preview/templateUtils';
|
||||
import {
|
||||
isTargetPropertySupported,
|
||||
classifyTrigger,
|
||||
parseActionSourcePath,
|
||||
parseComponentTargetPath,
|
||||
parseVariableTargetPath,
|
||||
} from '../Preview/bindingUtils';
|
||||
import styles from './ActionInspector.module.css';
|
||||
|
||||
// ── Diagnostic types ──────────────────────────────────────────────────────────
|
||||
|
||||
type DiagSeverity = 'warn' | 'info';
|
||||
|
||||
type Diagnostic = {
|
||||
severity: DiagSeverity;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* All diagnostics indexed by their subject ID so cards can look up their own.
|
||||
* Keys: action IDs, binding IDs.
|
||||
* Also a special key "__componentEvents" for component-event issues not tied to
|
||||
* a specific binding (currently unused — shown on binding cards that reference
|
||||
* the missing action).
|
||||
*/
|
||||
type DiagMap = Record<string, Diagnostic[]>;
|
||||
|
||||
// ── Pure diagnostic computation ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes all diagnostics from the project in a single pass.
|
||||
* Never throws — any unexpected input is handled gracefully.
|
||||
* Returns a DiagMap keyed by actionId or bindingId.
|
||||
*/
|
||||
function computeDiagnostics(
|
||||
actions: RestAction[],
|
||||
bindings: Binding[],
|
||||
allComponents: CanvasComponent[],
|
||||
variables: Record<string, Variable>,
|
||||
): DiagMap {
|
||||
const declaredVariableNames = new Set(Object.keys(variables));
|
||||
const map: DiagMap = {};
|
||||
|
||||
function add(key: string, severity: DiagSeverity, message: string) {
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push({ severity, message });
|
||||
}
|
||||
|
||||
const actionIds = new Set(actions.map((a) => a.id));
|
||||
const componentsByName = new Map(allComponents.map((c) => [c.name, c]));
|
||||
|
||||
// ── Index: which actionIds are triggered by at least one component event ──
|
||||
const triggeredActionIds = new Set<string>();
|
||||
for (const comp of allComponents) {
|
||||
for (const ev of comp.events ?? []) {
|
||||
triggeredActionIds.add(ev.actionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Index: for a given actionId, which components have an onClick event ───
|
||||
// key: actionId → component names that fire it via onClick
|
||||
const onClickTriggers = new Map<string, string[]>();
|
||||
for (const comp of allComponents) {
|
||||
for (const ev of comp.events ?? []) {
|
||||
if (ev.event === 'onClick') {
|
||||
const list = onClickTriggers.get(ev.actionId) ?? [];
|
||||
list.push(comp.name);
|
||||
onClickTriggers.set(ev.actionId, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Diagnostics per action ─────────────────────────────────────────────
|
||||
|
||||
for (const action of actions) {
|
||||
// 1a. Untriggered action
|
||||
if (!triggeredActionIds.has(action.id)) {
|
||||
add(
|
||||
action.id,
|
||||
'info',
|
||||
`Action "${action.name}" is not triggered by any component event. ` +
|
||||
`Add an events entry on a component: { "event": "onClick", "actionId": "${action.id}" }`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1b. Template diagnostics (Step 16.5)
|
||||
const { tokens, malformed } = extractTemplates(action);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.namespace === 'variables') {
|
||||
// Variable token in pathParameters — not supported in Step 18.1.
|
||||
// Warn regardless of whether the variable is declared, because the
|
||||
// runtime does not interpolate pathParameters values at all.
|
||||
if (token.location.startsWith('pathParameters.')) {
|
||||
add(
|
||||
action.id,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} uses a variable placeholder ` +
|
||||
`in a path-parameter value. Variable interpolation is not supported in ` +
|
||||
`pathParameters in Step 18.1. ` +
|
||||
`Supported locations: url, headers, queryParameters, bodyTemplate.`,
|
||||
);
|
||||
// Still check declaration so the user knows if the variable is also missing,
|
||||
// but use a separate, clearly scoped message.
|
||||
if (!declaredVariableNames.has(token.name)) {
|
||||
add(
|
||||
action.id,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} also references variable ` +
|
||||
`"${token.name}" which is not declared in project.variables.`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Variable token in a supported location — check declaration (Step 18.1)
|
||||
if (!declaredVariableNames.has(token.name)) {
|
||||
add(
|
||||
action.id,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} references variable ` +
|
||||
`"${token.name}" which is not declared in project.variables.`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Component token — check property and existence
|
||||
if (token.propertyName !== 'value') {
|
||||
add(
|
||||
action.id,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} references property ` +
|
||||
`"${token.propertyName ?? '?'}". Only "value" is supported. ` +
|
||||
`Use {{components.<name>.value}}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Missing component
|
||||
if (token.componentName && !componentsByName.has(token.componentName)) {
|
||||
add(
|
||||
action.id,
|
||||
'warn',
|
||||
`Template "${token.raw}" at ${token.location} references component ` +
|
||||
`"${token.componentName}" which does not exist on any page.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bad of malformed) {
|
||||
const isMalformedVar = classifyVariableExpression(bad.raw) === 'malformed';
|
||||
add(
|
||||
action.id,
|
||||
'warn',
|
||||
isMalformedVar
|
||||
? `Malformed variable template "${bad.raw}" at ${bad.location}: ` +
|
||||
`expected {{variables.<name>}} where <name> contains no dots. ` +
|
||||
`The action cannot execute while this expression is present.`
|
||||
: `Malformed template at ${bad.location}: "${bad.raw}…" — ` +
|
||||
`missing closing "}}". Check your template syntax.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Index: component name → all components with that name (duplicate check) ─
|
||||
const componentsByNameAll = new Map<string, CanvasComponent[]>();
|
||||
for (const comp of allComponents) {
|
||||
const list = componentsByNameAll.get(comp.name) ?? [];
|
||||
list.push(comp);
|
||||
componentsByNameAll.set(comp.name, list);
|
||||
}
|
||||
|
||||
// ── 2. Diagnostics per binding ────────────────────────────────────────────
|
||||
|
||||
for (const binding of bindings) {
|
||||
// Parse source using bindingUtils (handles extended paths like .response.body.field)
|
||||
const parsedSource = parseActionSourcePath(binding.source);
|
||||
|
||||
// Legacy: also handle component-sourced bindings (non-action sources)
|
||||
const sourceComponentMatch = /^components\.([^.]+)\./.exec(binding.source);
|
||||
const sourceComponentName = sourceComponentMatch ? sourceComponentMatch[1] : null;
|
||||
|
||||
const sourceActionId = parsedSource?.actionId ?? null;
|
||||
|
||||
// Parse targets (both component and variable)
|
||||
const parsedTarget = parseComponentTargetPath(binding.target);
|
||||
const parsedVariableTarget = parseVariableTargetPath(binding.target);
|
||||
const targetComponentName = parsedTarget?.componentName ?? null;
|
||||
|
||||
// 2a. Source action missing
|
||||
if (sourceActionId && !actionIds.has(sourceActionId)) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Source references action "${sourceActionId}" which does not exist in project.actions.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2a2. Source begins with "actions." but doesn't parse as a valid response path
|
||||
if (binding.source.startsWith('actions.') && !parsedSource) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Source "${binding.source}" does not follow the supported action-response path grammar. ` +
|
||||
`Expected: actions.<actionId>.response[.body[.<field…>]]`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2b. Source component missing (for component-sourced bindings)
|
||||
if (sourceComponentName && !componentsByName.has(sourceComponentName)) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Source references component "${sourceComponentName}" which does not exist on any page.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2c. Variable target diagnostics (Step 18.1)
|
||||
if (parsedVariableTarget) {
|
||||
const { variableName } = parsedVariableTarget;
|
||||
|
||||
// 2c1. Variable must be declared
|
||||
if (!declaredVariableNames.has(variableName)) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Target "variables.${variableName}" references a variable that is not declared ` +
|
||||
`in project.variables. Declare the variable before using it as a binding target.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2c2. Variable response bindings require onSuccess — onClick (legacy) is
|
||||
// not accepted for variable targets (unlike component targets).
|
||||
if (sourceActionId) {
|
||||
const triggerClass = classifyTrigger(binding.trigger);
|
||||
if (triggerClass !== 'onSuccess') {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Variable response bindings require trigger "onSuccess". ` +
|
||||
`Got "${binding.trigger ?? 'onChange'}". ` +
|
||||
`Change the trigger to "onSuccess".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (!parsedTarget) {
|
||||
// 2d. Target path doesn't start with "components." or "variables."
|
||||
if (!binding.target.startsWith('components.') && !binding.target.startsWith('variables.')) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Target "${binding.target}" is not a recognised path. ` +
|
||||
`Use "components.<name>.<property>" or "variables.<name>".`,
|
||||
);
|
||||
} else {
|
||||
// Starts with a recognised prefix but didn't parse — malformed
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Target "${binding.target}" could not be parsed. ` +
|
||||
`For components use "components.<name>.<property>"; for variables use "variables.<name>".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2d. Target component missing
|
||||
if (targetComponentName) {
|
||||
const candidates = componentsByNameAll.get(targetComponentName) ?? [];
|
||||
|
||||
if (candidates.length === 0) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Target references component "${targetComponentName}" which does not exist on any page.`,
|
||||
);
|
||||
} else if (candidates.length > 1) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Target component name "${targetComponentName}" is ambiguous — ` +
|
||||
`${candidates.length} components share this name. Binding resolution will fail at runtime.`,
|
||||
);
|
||||
} else {
|
||||
const comp = candidates[0];
|
||||
|
||||
// 2d2. Target property + component type must be a supported combination
|
||||
if (parsedTarget && sourceActionId && !isTargetPropertySupported(comp.type, parsedTarget.property)) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Target property "${parsedTarget.property}" is not supported for ` +
|
||||
`component type "${comp.type}". ` +
|
||||
`Supported: .value → JsonViewer, Label; .options → Dropdown; .rows → Table.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2e. No triggering component fires the source action at all
|
||||
if (sourceActionId && actionIds.has(sourceActionId) && !triggeredActionIds.has(sourceActionId)) {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`No component has an event that fires action "${sourceActionId}". ` +
|
||||
`The binding will never receive a response. ` +
|
||||
`Add events: [{ "event": "onClick", "actionId": "${sourceActionId}" }] to a Button.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2f. Trigger classification for component targets (Step 17.1)
|
||||
// Variable targets already had trigger checked in 2c — skip to avoid duplicates.
|
||||
if (sourceActionId && !parsedVariableTarget) {
|
||||
const triggerClass = classifyTrigger(binding.trigger);
|
||||
|
||||
if (triggerClass === 'unsupported') {
|
||||
add(
|
||||
binding.id,
|
||||
'warn',
|
||||
`Trigger "${binding.trigger ?? 'onChange'}" is not supported for action-response ` +
|
||||
`bindings. Use "onSuccess" (or "onClick" for legacy compatibility).`,
|
||||
);
|
||||
} else if (triggerClass === 'onClick-legacy') {
|
||||
add(
|
||||
binding.id,
|
||||
'info',
|
||||
`Trigger "onClick" is a legacy response-mapping trigger. ` +
|
||||
`Consider migrating to "onSuccess".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Diagnostics from component events (shown on bindings) ─────────────
|
||||
// For each component event that references a missing action, find the
|
||||
// bindings that reference that action and add the warning there.
|
||||
// If no such binding exists, add a synthetic entry keyed by
|
||||
// "__event_<component.name>_<actionId>".
|
||||
|
||||
for (const comp of allComponents) {
|
||||
for (const ev of comp.events ?? []) {
|
||||
if (!actionIds.has(ev.actionId)) {
|
||||
// Find bindings that reference this action (any source path form)
|
||||
const relatedBindings = bindings.filter((b) => {
|
||||
const parsed = parseActionSourcePath(b.source);
|
||||
return parsed ? parsed.actionId === ev.actionId : false;
|
||||
});
|
||||
|
||||
const msg =
|
||||
`Component "${comp.name}" has event "${ev.event}" referencing ` +
|
||||
`action "${ev.actionId}" which does not exist in project.actions.`;
|
||||
|
||||
if (relatedBindings.length > 0) {
|
||||
for (const b of relatedBindings) add(b.id, 'warn', msg);
|
||||
} else {
|
||||
// No binding references this event — key by component+action
|
||||
add(`__event_${comp.name}_${ev.actionId}`, 'warn', msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Diagnostics per Table component ───────────────────────────────────
|
||||
|
||||
for (const comp of allComponents) {
|
||||
if (comp.type !== 'Table') continue;
|
||||
|
||||
const rawColumns = comp.properties.columns;
|
||||
const columns: TableColumn[] = Array.isArray(rawColumns)
|
||||
? (rawColumns as TableColumn[]).filter(
|
||||
(c) => c && typeof c.key === 'string' && typeof c.header === 'string',
|
||||
)
|
||||
: [];
|
||||
|
||||
// 4a. Duplicate column keys
|
||||
const colKeys = columns.map((c) => c.key).filter(Boolean);
|
||||
const seenKeys = new Set<string>();
|
||||
const dupKeys = new Set<string>();
|
||||
for (const k of colKeys) {
|
||||
if (seenKeys.has(k)) dupKeys.add(k);
|
||||
seenKeys.add(k);
|
||||
}
|
||||
if (dupKeys.size > 0) {
|
||||
add(
|
||||
`__table_${comp.name}`,
|
||||
'warn',
|
||||
`Table "${comp.name}" has duplicate column keys: ` +
|
||||
`${[...dupKeys].map((k) => `"${k}"`).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4b. Empty column key
|
||||
if (columns.some((c) => !c.key)) {
|
||||
add(
|
||||
`__table_${comp.name}`,
|
||||
'warn',
|
||||
`Table "${comp.name}" has a column with an empty key. Edit columns in the property editor.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Diagnostics per Dropdown component ────────────────────────────────
|
||||
|
||||
for (const comp of allComponents) {
|
||||
if (comp.type !== 'Dropdown') continue;
|
||||
|
||||
const rawOptions = comp.properties.options;
|
||||
const options: DropdownOption[] = Array.isArray(rawOptions)
|
||||
? (rawOptions as DropdownOption[]).filter(
|
||||
(o) => o && typeof o.label === 'string' && typeof o.value === 'string',
|
||||
)
|
||||
: [];
|
||||
|
||||
// 4a. Duplicate option values
|
||||
const optionValues = options.map((o) => o.value).filter(Boolean);
|
||||
const seenValues = new Set<string>();
|
||||
const dupValues = new Set<string>();
|
||||
for (const v of optionValues) {
|
||||
if (seenValues.has(v)) dupValues.add(v);
|
||||
seenValues.add(v);
|
||||
}
|
||||
if (dupValues.size > 0) {
|
||||
add(
|
||||
`__dropdown_${comp.name}`,
|
||||
'warn',
|
||||
`Dropdown "${comp.name}" has duplicate option values: ` +
|
||||
`${[...dupValues].map((v) => `"${v}"`).join(', ')}. ` +
|
||||
`Runtime selection may be ambiguous.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4b. Configured value not in options
|
||||
const configuredValue = typeof comp.properties.value === 'string'
|
||||
? comp.properties.value
|
||||
: '';
|
||||
if (configuredValue !== '' && !options.some((o) => o.value === configuredValue)) {
|
||||
add(
|
||||
`__dropdown_${comp.name}`,
|
||||
'warn',
|
||||
`Dropdown "${comp.name}" configured value "${configuredValue}" is not present ` +
|
||||
`in its options list. Preview will show the placeholder instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4c. Options with empty label or value
|
||||
for (const opt of options) {
|
||||
if (!opt.label || !opt.value) {
|
||||
add(
|
||||
`__dropdown_${comp.name}`,
|
||||
'warn',
|
||||
`Dropdown "${comp.name}" has an option with an empty ` +
|
||||
`${!opt.label ? 'label' : 'value'}. ` +
|
||||
`Edit options in the property editor.`,
|
||||
);
|
||||
break; // one warning is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function methodClass(method: string): string {
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET': return styles.methodGet;
|
||||
case 'POST': return styles.methodPost;
|
||||
case 'PUT': return styles.methodPut;
|
||||
case 'PATCH': return styles.methodPatch;
|
||||
case 'DELETE': return styles.methodDelete;
|
||||
default: return styles.methodOther;
|
||||
}
|
||||
}
|
||||
|
||||
function parseActionSource(expr: string): string | null {
|
||||
const m = /^actions\.([^.]+)\.response$/.exec(expr);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function parseComponentExpr(expr: string): string | null {
|
||||
const m = /^components\.([^.]+)\./.exec(expr);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
const AUTH_LABELS: Record<string, string> = {
|
||||
anonymous: 'Anonymous',
|
||||
bearerToken: 'Bearer Token',
|
||||
basicAuth: 'Basic Auth',
|
||||
apiKeyHeader: 'API Key (Header)',
|
||||
apiKeyQueryParameter: 'API Key (Query)',
|
||||
};
|
||||
|
||||
// ── DiagList sub-component ────────────────────────────────────────────────────
|
||||
|
||||
function DiagList({ diags }: { diags: Diagnostic[] }): React.ReactElement | null {
|
||||
if (diags.length === 0) return null;
|
||||
return (
|
||||
<div className={styles.diagList}>
|
||||
{diags.map((d, i) => (
|
||||
d.severity === 'warn' ? (
|
||||
<div key={i} className={styles.diagWarn}>
|
||||
<em className={styles.diagWarnIcon}>⚠</em>
|
||||
<span>{d.message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className={styles.diagInfo}>
|
||||
<em className={styles.diagInfoIcon}>ℹ</em>
|
||||
<span>{d.message}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ActionCard ────────────────────────────────────────────────────────────────
|
||||
|
||||
type TestState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'running' }
|
||||
| { status: 'ok'; response: ProxyResponse }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
type ActionCardProps = {
|
||||
action: RestAction;
|
||||
diags: Diagnostic[];
|
||||
allComponents: CanvasComponent[];
|
||||
};
|
||||
|
||||
function ActionCard({ action, diags, allComponents }: ActionCardProps): React.ReactElement {
|
||||
const [test, setTest] = useState<TestState>({ status: 'idle' });
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setTest({ status: 'running' });
|
||||
try {
|
||||
const response = await executeAction(action);
|
||||
setTest({ status: 'ok', response });
|
||||
} catch (err) {
|
||||
setTest({
|
||||
status: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, [action]);
|
||||
|
||||
// Compute template info for display
|
||||
const { tokens, malformed } = useMemo(
|
||||
() => extractTemplates(action),
|
||||
[action],
|
||||
);
|
||||
const componentsByName = useMemo(
|
||||
() => new Map(allComponents.map((c) => [c.name, c])),
|
||||
[allComponents],
|
||||
);
|
||||
const hasTemplates = tokens.length > 0 || malformed.length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.actionCard}>
|
||||
{/* ── Header: method + name + id ── */}
|
||||
<div className={styles.actionCardHeader}>
|
||||
<span className={[styles.methodBadge, methodClass(action.method)].join(' ')}>
|
||||
{action.method}
|
||||
</span>
|
||||
<span className={styles.actionName}>{action.name}</span>
|
||||
<span className={styles.actionId}>{action.id}</span>
|
||||
</div>
|
||||
|
||||
{/* ── URL ── */}
|
||||
<div className={styles.actionUrl}>{action.url}</div>
|
||||
|
||||
{/* ── Auth + description ── */}
|
||||
<div className={styles.actionMeta}>
|
||||
<span className={styles.authBadge}>
|
||||
{AUTH_LABELS[action.authenticationType] ?? action.authenticationType}
|
||||
</span>
|
||||
{action.description && (
|
||||
<span className={styles.actionDescription}>{action.description}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Template references (Step 16.5) ── */}
|
||||
{hasTemplates && (
|
||||
<div className={styles.templateSection}>
|
||||
<div className={styles.templateSectionTitle}>Template References</div>
|
||||
{tokens.map((token, i) => {
|
||||
const comp = token.componentName ? componentsByName.get(token.componentName) : null;
|
||||
const resolved = comp !== null && comp !== undefined;
|
||||
const unsupportedProp = token.propertyName !== 'value';
|
||||
return (
|
||||
<div key={i} className={styles.templateRow}>
|
||||
<code className={styles.templateRaw}>{token.raw}</code>
|
||||
<span className={styles.templateLocation}>{token.location}</span>
|
||||
{unsupportedProp ? (
|
||||
<span className={styles.templateUnresolved}>
|
||||
unsupported property “{token.propertyName}”
|
||||
</span>
|
||||
) : resolved ? (
|
||||
<span className={styles.templateResolved}>
|
||||
{comp!.name} / {comp!.type}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.templateUnresolved}>
|
||||
component “{token.componentName}” not found
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{malformed.map((bad, i) => (
|
||||
<div key={`bad-${i}`} className={styles.templateRow}>
|
||||
<code className={styles.templateRaw}>{bad.raw}…</code>
|
||||
<span className={styles.templateLocation}>{bad.location}</span>
|
||||
<span className={styles.templateUnresolved}>malformed — missing {{}} closing braces</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Diagnostics ── */}
|
||||
<DiagList diags={diags} />
|
||||
|
||||
{/* ── Test Action ── */}
|
||||
<div className={styles.testRow}>
|
||||
<button
|
||||
className={styles.testBtn}
|
||||
disabled={test.status === 'running'}
|
||||
onClick={handleTest}
|
||||
>
|
||||
{test.status === 'running' ? 'Running…' : 'Test Action'}
|
||||
</button>
|
||||
|
||||
{test.status === 'running' && (
|
||||
<span className={styles.testStatus}>Calling proxy…</span>
|
||||
)}
|
||||
|
||||
{test.status === 'ok' && (
|
||||
<div className={styles.testResult}>
|
||||
<div className={styles.testStatus}>
|
||||
HTTP {test.response.status} {test.response.statusText}
|
||||
{' · '}{test.response.durationMs} ms
|
||||
{' · '}{test.response.ok ? '✓ ok' : '✗ non-2xx'}
|
||||
</div>
|
||||
<pre className={styles.testResultPre}>
|
||||
{JSON.stringify(test.response.body, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{test.status === 'error' && (
|
||||
<div className={styles.testResult}>
|
||||
<pre className={[styles.testResultPre, styles.testResultError].join(' ')}>
|
||||
{test.message}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BindingCard ───────────────────────────────────────────────────────────────
|
||||
|
||||
type BindingCardProps = {
|
||||
binding: Binding;
|
||||
allComponents: CanvasComponent[];
|
||||
allActions: RestAction[];
|
||||
diags: Diagnostic[];
|
||||
};
|
||||
|
||||
function BindingCard({
|
||||
binding,
|
||||
allComponents,
|
||||
allActions,
|
||||
diags,
|
||||
}: BindingCardProps): React.ReactElement {
|
||||
// Resolve source
|
||||
const sourceActionId = parseActionSource(binding.source);
|
||||
const sourceAction = sourceActionId ? allActions.find((a) => a.id === sourceActionId) : null;
|
||||
const sourceComponentName = parseComponentExpr(binding.source);
|
||||
const sourceComponent = sourceComponentName
|
||||
? allComponents.find((c) => c.name === sourceComponentName)
|
||||
: null;
|
||||
|
||||
// Resolve target
|
||||
const targetComponentName = parseComponentExpr(binding.target);
|
||||
const targetComponent = targetComponentName
|
||||
? allComponents.find((c) => c.name === targetComponentName)
|
||||
: null;
|
||||
|
||||
const trigger = binding.trigger ?? 'onChange';
|
||||
|
||||
return (
|
||||
<div className={styles.bindingCard}>
|
||||
{/* ── Header: id + trigger ── */}
|
||||
<div className={styles.bindingCardHeader}>
|
||||
<span className={styles.bindingId}>{binding.id}</span>
|
||||
<span className={styles.triggerBadge}>{trigger}</span>
|
||||
</div>
|
||||
|
||||
{/* ── Source / Target flow ── */}
|
||||
<div className={styles.bindingFlow}>
|
||||
{/* Source */}
|
||||
<div className={styles.bindingRow}>
|
||||
<span className={styles.bindingLabel}>Source</span>
|
||||
<span className={styles.bindingExpr}>{binding.source}</span>
|
||||
{sourceAction && (
|
||||
<span className={styles.bindingResolved}>
|
||||
{sourceAction.method} {sourceAction.name}
|
||||
</span>
|
||||
)}
|
||||
{sourceComponent && (
|
||||
<span className={styles.bindingResolved}>
|
||||
{sourceComponent.name} / {sourceComponent.type}
|
||||
</span>
|
||||
)}
|
||||
{!sourceAction && !sourceComponent && sourceActionId && (
|
||||
<span className={styles.bindingUnresolved}>
|
||||
action “{sourceActionId}” not found
|
||||
</span>
|
||||
)}
|
||||
{!sourceAction && !sourceComponent && sourceComponentName && !sourceActionId && (
|
||||
<span className={styles.bindingUnresolved}>
|
||||
component “{sourceComponentName}” not found
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div className={styles.bindingRow}>
|
||||
<span className={styles.bindingLabel}>Target</span>
|
||||
<span className={styles.bindingExpr}>{binding.target}</span>
|
||||
{targetComponent ? (
|
||||
<span className={styles.bindingResolved}>
|
||||
{targetComponent.name} / {targetComponent.type}
|
||||
</span>
|
||||
) : targetComponentName ? (
|
||||
<span className={styles.bindingUnresolved}>
|
||||
component “{targetComponentName}” not found
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Diagnostics ── */}
|
||||
<DiagList diags={diags} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
function ActionInspector(): React.ReactElement {
|
||||
const { doc } = useProject();
|
||||
const { actions, bindings, pages, variables } = doc.project;
|
||||
|
||||
const allComponents = useMemo(
|
||||
() => pages.flatMap((p) => p.components),
|
||||
[pages],
|
||||
);
|
||||
|
||||
// Compute all diagnostics once per render cycle
|
||||
const diagMap = useMemo(
|
||||
() => computeDiagnostics(actions, bindings, allComponents, variables),
|
||||
[actions, bindings, allComponents, variables],
|
||||
);
|
||||
|
||||
// Count total warnings/infos for section summary banners
|
||||
const actionWarnings = actions.reduce(
|
||||
(n, a) => n + (diagMap[a.id]?.filter((d) => d.severity === 'warn').length ?? 0),
|
||||
0,
|
||||
);
|
||||
const actionInfos = actions.reduce(
|
||||
(n, a) => n + (diagMap[a.id]?.filter((d) => d.severity === 'info').length ?? 0),
|
||||
0,
|
||||
);
|
||||
const bindingWarnings = bindings.reduce(
|
||||
(n, b) => n + (diagMap[b.id]?.filter((d) => d.severity === 'warn').length ?? 0),
|
||||
0,
|
||||
);
|
||||
const totalIssues = actionWarnings + actionInfos + bindingWarnings;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{/* ── Page header ── */}
|
||||
<div className={styles.pageHeader}>
|
||||
<div className={styles.pageTitle}>Actions & Bindings</div>
|
||||
<div className={styles.pageSubtitle}>
|
||||
Read-only inspector for the current project's REST actions and
|
||||
bindings. Use the JSON Editor to modify definitions.
|
||||
The <strong>Test Action</strong> button calls the backend proxy
|
||||
directly. Diagnostics highlight broken references and missing wiring.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══ Actions section ══════════════════════════════════════════ */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span className={styles.sectionTitle}>REST Actions</span>
|
||||
<span className={styles.sectionCount}>{actions.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Summary banner */}
|
||||
{actions.length > 0 && (actionWarnings + actionInfos) === 0 && (
|
||||
<div className={styles.diagSummaryOk}>
|
||||
✓ No issues found
|
||||
</div>
|
||||
)}
|
||||
{actions.length > 0 && (actionWarnings + actionInfos) > 0 && (
|
||||
<div className={styles.diagSummary}>
|
||||
⚠ {actionWarnings + actionInfos} issue{actionWarnings + actionInfos !== 1 ? 's' : ''} detected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No REST actions defined. Add actions to{' '}
|
||||
<code>project.actions</code> in the JSON Editor.
|
||||
</div>
|
||||
) : (
|
||||
actions.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
diags={diagMap[action.id] ?? []}
|
||||
allComponents={allComponents}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ══ Bindings section ═════════════════════════════════════════ */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span className={styles.sectionTitle}>Bindings</span>
|
||||
<span className={styles.sectionCount}>{bindings.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Summary banner */}
|
||||
{bindings.length > 0 && bindingWarnings === 0 && (
|
||||
<div className={styles.diagSummaryOk}>
|
||||
✓ No issues found
|
||||
</div>
|
||||
)}
|
||||
{bindings.length > 0 && bindingWarnings > 0 && (
|
||||
<div className={styles.diagSummary}>
|
||||
⚠ {bindingWarnings} issue{bindingWarnings !== 1 ? 's' : ''} detected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bindings.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No bindings defined. Add bindings to{' '}
|
||||
<code>project.bindings</code> in the JSON Editor.
|
||||
</div>
|
||||
) : (
|
||||
bindings.map((binding) => (
|
||||
<BindingCard
|
||||
key={binding.id}
|
||||
binding={binding}
|
||||
allComponents={allComponents}
|
||||
allActions={actions}
|
||||
diags={diagMap[binding.id] ?? []}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ══ Overall summary (only shown when there are issues) ═══════ */}
|
||||
{totalIssues === 0 && (actions.length > 0 || bindings.length > 0) && (
|
||||
<div style={{ fontSize: 12, color: '#15803d' }}>
|
||||
✓ All actions and bindings are consistent.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionInspector;
|
||||
321
frontend/src/components/JsonEditor/JsonEditor.module.css
Normal file
321
frontend/src/components/JsonEditor/JsonEditor.module.css
Normal file
@ -0,0 +1,321 @@
|
||||
/* The JSON editor fills the full main area, same as the Visual Editor. */
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: -32px;
|
||||
height: calc(100% + 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header bar ──────────────────────────────────────────────────── */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background: #f7f8fa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 4px 12px;
|
||||
background: #1f6feb;
|
||||
border: 1px solid #1f6feb;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #388bfd;
|
||||
border-color: #388bfd;
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btnDisabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnSecondary {
|
||||
padding: 4px 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.btnSecondary:hover:not(:disabled) {
|
||||
background: #f7f8fa;
|
||||
border-color: #8b949e;
|
||||
}
|
||||
|
||||
.btnSecondary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Body: textarea | sidebar ────────────────────────────────────── */
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Textarea ─────────────────────────────────────────────────────── */
|
||||
|
||||
.textareaWrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 16px 20px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #e6edf3;
|
||||
background: #0d1117;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.textarea::selection {
|
||||
background: #264f78;
|
||||
}
|
||||
|
||||
.textareaError {
|
||||
box-shadow: inset 3px 0 0 #f85149;
|
||||
}
|
||||
|
||||
.textareaDirty {
|
||||
box-shadow: inset 3px 0 0 #f59e0b;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: #f7f8fa;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebarHeader {
|
||||
padding: 10px 14px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #8b949e;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebarSection {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Status messages ──────────────────────────────────────────────── */
|
||||
|
||||
.statusOk {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.statusPending {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Error block ──────────────────────────────────────────────────── */
|
||||
|
||||
.errorBlock {
|
||||
margin: 8px 10px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 5px;
|
||||
background: #fef2f2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.errorTitle {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
border-bottom: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
padding: 8px 10px;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: #991b1b;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.errorList {
|
||||
list-style: none;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.errorItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #fecaca;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.errorItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.errorPath {
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 10px;
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.errorDetail {
|
||||
font-size: 11px;
|
||||
color: #7f1d1d;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Document info table ──────────────────────────────────────────── */
|
||||
|
||||
.schemaNote {
|
||||
padding: 8px 14px;
|
||||
font-size: 11px;
|
||||
color: #57606a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.schemaNote code {
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 10px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 4px 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.infoRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.infoKey {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.infoVal {
|
||||
font-size: 12px;
|
||||
color: #1f2328;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ── REST Action list ─────────────────────────────────────────────── */
|
||||
|
||||
.actionList {
|
||||
list-style: none;
|
||||
padding: 4px 8px 6px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.actionItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: #eef2f7;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actionMethod {
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
border-radius: 2px;
|
||||
padding: 1px 4px;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.actionName {
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 11px;
|
||||
}
|
||||
277
frontend/src/components/JsonEditor/JsonEditor.tsx
Normal file
277
frontend/src/components/JsonEditor/JsonEditor.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ProjectToolbar from '../ProjectToolbar/ProjectToolbar';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import type { ProjectDocument } from '../../types/project';
|
||||
import styles from './JsonEditor.module.css';
|
||||
|
||||
// ── Schema validation via the backend endpoint ────────────────────────────────
|
||||
|
||||
type ValidationError = { path: string; message: string };
|
||||
type ValidationResult =
|
||||
| { valid: true }
|
||||
| { valid: false; errors: ValidationError[] };
|
||||
|
||||
async function validateAgainstSchema(doc: unknown): Promise<ValidationResult> {
|
||||
const res = await fetch('/api/projects/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
|
||||
// 200 — valid document
|
||||
if (res.status === 200) {
|
||||
return res.json() as Promise<ValidationResult>;
|
||||
}
|
||||
|
||||
// 422 — document is syntactically valid JSON but fails schema validation.
|
||||
// The body is { valid: false, errors: [...] } — return it as a result, not an error.
|
||||
if (res.status === 422) {
|
||||
return res.json() as Promise<ValidationResult>;
|
||||
}
|
||||
|
||||
// 400 — request body was not parseable JSON (should not happen here, but handle cleanly)
|
||||
if (res.status === 400) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? 'Request was rejected by the validation endpoint (HTTP 400).');
|
||||
}
|
||||
|
||||
// 500 or any other unexpected status — validation service is genuinely unavailable
|
||||
throw new Error(`Validation service returned HTTP ${res.status}. Check server logs.`);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function JsonEditor(): React.ReactElement {
|
||||
const { doc, setDoc } = useProject();
|
||||
|
||||
// ── Local textarea state ──────────────────────────────────────────────────
|
||||
// The textarea holds a string that may temporarily diverge from `doc`
|
||||
// while the user is typing. We only push to context when Apply is clicked.
|
||||
|
||||
const [text, setText] = useState(() => JSON.stringify(doc, null, 2));
|
||||
const [syntaxError, setSyntaxError] = useState<string | null>(null);
|
||||
const [schemaErrors, setSchemaErrors] = useState<ValidationError[]>([]);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [lastApplied, setLastApplied] = useState<string | null>(null);
|
||||
|
||||
// Track whether textarea text was externally driven (context changed from
|
||||
// Visual Editor) vs. locally typed (user is editing here).
|
||||
const userEditingRef = useRef(false);
|
||||
|
||||
// ── Sync: context → textarea ──────────────────────────────────────────────
|
||||
// When the Visual Editor (or Load) changes `doc`, update the textarea —
|
||||
// but only if the user is not mid-edit in the JSON Editor.
|
||||
useEffect(() => {
|
||||
if (userEditingRef.current) return;
|
||||
const serialised = JSON.stringify(doc, null, 2);
|
||||
setText(serialised);
|
||||
setSyntaxError(null);
|
||||
setSchemaErrors([]);
|
||||
setLastApplied(serialised);
|
||||
}, [doc]);
|
||||
|
||||
// ── Textarea change handler ───────────────────────────────────────────────
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
userEditingRef.current = true;
|
||||
setText(e.target.value);
|
||||
setSyntaxError(null); // clear stale errors while typing
|
||||
setSchemaErrors([]);
|
||||
}, []);
|
||||
|
||||
// ── Apply: validate then push to context ─────────────────────────────────
|
||||
const handleApply = useCallback(async () => {
|
||||
// Step 1: syntax check
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
setSyntaxError(
|
||||
err instanceof SyntaxError ? err.message : 'Invalid JSON syntax.',
|
||||
);
|
||||
setSchemaErrors([]);
|
||||
return;
|
||||
}
|
||||
setSyntaxError(null);
|
||||
|
||||
// Step 2: schema validation via backend
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const result = await validateAgainstSchema(parsed);
|
||||
if (!result.valid) {
|
||||
setSchemaErrors(result.errors);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Network / endpoint error — still show a clear message but do not apply
|
||||
setSyntaxError(
|
||||
`Schema validation unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
|
||||
setSchemaErrors([]);
|
||||
|
||||
// Step 3: push to context (triggers Visual Editor re-render)
|
||||
setDoc(() => parsed as ProjectDocument);
|
||||
userEditingRef.current = false;
|
||||
setLastApplied(text);
|
||||
}, [text, setDoc]);
|
||||
|
||||
// ── Revert: reset textarea to current context doc ─────────────────────────
|
||||
const handleRevert = useCallback(() => {
|
||||
const serialised = JSON.stringify(doc, null, 2);
|
||||
setText(serialised);
|
||||
setSyntaxError(null);
|
||||
setSchemaErrors([]);
|
||||
setLastApplied(serialised);
|
||||
userEditingRef.current = false;
|
||||
}, [doc]);
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────────────────────
|
||||
const isDirtyLocal = text !== lastApplied;
|
||||
const hasErrors = syntaxError !== null || schemaErrors.length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.editor}>
|
||||
{/* ── Project toolbar (shared with Visual Editor) ───────────── */}
|
||||
<ProjectToolbar />
|
||||
|
||||
{/* ── Editor header ─────────────────────────────────────────── */}
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>JSON Editor</span>
|
||||
<span className={styles.hint}>
|
||||
Edit the canonical project JSON. Click <strong>Apply</strong> to
|
||||
validate and update the project. Changes will be reflected in the
|
||||
Visual Editor immediately.
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={[styles.btn, hasErrors && styles.btnDisabled].filter(Boolean).join(' ')}
|
||||
onClick={handleApply}
|
||||
disabled={isValidating || !isDirtyLocal}
|
||||
title="Validate and apply changes to the project"
|
||||
>
|
||||
{isValidating ? 'Validating…' : 'Apply'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.btnSecondary}
|
||||
onClick={handleRevert}
|
||||
disabled={!isDirtyLocal}
|
||||
title="Discard local edits and revert to current project state"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main area: textarea + error panel ─────────────────────── */}
|
||||
<div className={styles.body}>
|
||||
{/* Textarea */}
|
||||
<div className={styles.textareaWrapper}>
|
||||
<textarea
|
||||
className={[
|
||||
styles.textarea,
|
||||
hasErrors ? styles.textareaError : '',
|
||||
isDirtyLocal && !hasErrors ? styles.textareaDirty : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
aria-label="Project JSON"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error / status sidebar */}
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>Validation</div>
|
||||
|
||||
{!hasErrors && !isDirtyLocal && (
|
||||
<p className={styles.statusOk}>✓ JSON matches project state</p>
|
||||
)}
|
||||
|
||||
{!hasErrors && isDirtyLocal && (
|
||||
<p className={styles.statusPending}>
|
||||
Unsaved local edits — click <strong>Apply</strong> to validate.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{syntaxError && (
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorTitle}>Syntax error</div>
|
||||
<pre className={styles.errorMsg}>{syntaxError}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schemaErrors.length > 0 && (
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorTitle}>
|
||||
Schema errors ({schemaErrors.length})
|
||||
</div>
|
||||
<ul className={styles.errorList}>
|
||||
{schemaErrors.map((e, i) => (
|
||||
<li key={i} className={styles.errorItem}>
|
||||
<span className={styles.errorPath}>{e.path}</span>
|
||||
<span className={styles.errorDetail}>{e.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarHeader}>Schema</div>
|
||||
<p className={styles.schemaNote}>
|
||||
Validated against{' '}
|
||||
<code>conductor-project.schema.json</code> v0.1.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarHeader}>Document info</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoKey}>Version</span>
|
||||
<span className={styles.infoVal}>{doc.schemaVersion}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoKey}>Pages</span>
|
||||
<span className={styles.infoVal}>{doc.project.pages.length}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoKey}>Components</span>
|
||||
<span className={styles.infoVal}>
|
||||
{doc.project.pages.reduce(
|
||||
(acc, p) => acc + p.components.length,
|
||||
0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoKey}>Actions</span>
|
||||
<span className={styles.infoVal}>{doc.project.actions.length}</span>
|
||||
</div>
|
||||
{doc.project.actions.length > 0 && (
|
||||
<ul className={styles.actionList}>
|
||||
{doc.project.actions.map((a) => (
|
||||
<li key={a.id} className={styles.actionItem}>
|
||||
<span className={styles.actionMethod}>{a.method}</span>
|
||||
<span className={styles.actionName}>{a.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoKey}>Chars</span>
|
||||
<span className={styles.infoVal}>{text.length.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditor;
|
||||
27
frontend/src/components/Layout/Header.module.css
Normal file
27
frontend/src/components/Layout/Header.module.css
Normal file
@ -0,0 +1,27 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
background: #1f2328;
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.logoAccent {
|
||||
color: #3b82d4;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin-left: 16px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
15
frontend/src/components/Layout/Header.tsx
Normal file
15
frontend/src/components/Layout/Header.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import styles from './Header.module.css';
|
||||
|
||||
function Header(): React.ReactElement {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<span className={styles.logo}>
|
||||
<span className={styles.logoAccent}>Conductor</span>
|
||||
</span>
|
||||
<span className={styles.tagline}>REST UI Builder</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
18
frontend/src/components/Layout/Layout.module.css
Normal file
18
frontend/src/components/Layout/Layout.module.css
Normal file
@ -0,0 +1,18 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
background: #ffffff;
|
||||
}
|
||||
24
frontend/src/components/Layout/Layout.tsx
Normal file
24
frontend/src/components/Layout/Layout.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import styles from './Layout.module.css';
|
||||
|
||||
type LayoutProps = {
|
||||
activeItem: string;
|
||||
onNavigate: (id: string) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Layout({ activeItem, onNavigate, children }: LayoutProps): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<Header />
|
||||
<div className={styles.body}>
|
||||
<Sidebar activeItem={activeItem} onNavigate={onNavigate} />
|
||||
<main className={styles.main}>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
50
frontend/src/components/Layout/Sidebar.module.css
Normal file
50
frontend/src/components/Layout/Sidebar.module.css
Normal file
@ -0,0 +1,50 @@
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
background: #f7f8fa;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
padding: 0 16px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.navList {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: #1f2328;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.navItemActive:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
33
frontend/src/components/Layout/Sidebar.tsx
Normal file
33
frontend/src/components/Layout/Sidebar.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import styles from './Sidebar.module.css';
|
||||
import { NAV_ITEMS } from './navItems';
|
||||
|
||||
type SidebarProps = {
|
||||
activeItem: string;
|
||||
onNavigate: (id: string) => void;
|
||||
};
|
||||
|
||||
function Sidebar({ activeItem, onNavigate }: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<nav className={styles.sidebar}>
|
||||
<span className={styles.sectionLabel}>Navigation</span>
|
||||
<ul className={styles.navList}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className={[
|
||||
styles.navItem,
|
||||
activeItem === item.id ? styles.navItemActive : '',
|
||||
].join(' ')}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
12
frontend/src/components/Layout/navItems.ts
Normal file
12
frontend/src/components/Layout/navItems.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'projects', label: 'Projects' },
|
||||
{ id: 'visual-editor', label: 'Visual Editor' },
|
||||
{ id: 'json-editor', label: 'JSON Editor' },
|
||||
{ id: 'preview', label: 'Preview' },
|
||||
{ id: 'inspector', label: 'Actions & Bindings' },
|
||||
];
|
||||
134
frontend/src/components/Preview/Preview.module.css
Normal file
134
frontend/src/components/Preview/Preview.module.css
Normal file
@ -0,0 +1,134 @@
|
||||
/* Preview fills the full main area, same as the other editor views. */
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: -32px;
|
||||
height: calc(100% + 64px);
|
||||
overflow: hidden;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────── */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 44px;
|
||||
padding: 0 18px;
|
||||
background: #1f2328;
|
||||
border-bottom: 1px solid #30363d;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
background: #16a34a;
|
||||
color: #ffffff;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.projectName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Page tabs ───────────────────────────────────────────────────── */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #f7f8fa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
color: #57606a;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: #3b82d4;
|
||||
border-bottom-color: #3b82d4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Canvas scroll area ──────────────────────────────────────────── */
|
||||
|
||||
.canvasScroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* ── Canvas surface (white "page") ──────────────────────────────── */
|
||||
|
||||
.canvas {
|
||||
position: relative;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────── */
|
||||
|
||||
.empty {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
line-height: 1.6;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
98
frontend/src/components/Preview/Preview.tsx
Normal file
98
frontend/src/components/Preview/Preview.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import { usePreviewRuntime } from './usePreviewRuntime';
|
||||
import PreviewComponent from './PreviewComponent';
|
||||
import styles from './Preview.module.css';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Compute the minimum canvas dimensions needed to fit all components. */
|
||||
function canvasBounds(components: { position: { x: number; y: number }; size: { width: number; height: number } }[]): { width: number; height: number } {
|
||||
if (components.length === 0) return { width: 800, height: 600 };
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
for (const c of components) {
|
||||
maxX = Math.max(maxX, c.position.x + c.size.width);
|
||||
maxY = Math.max(maxY, c.position.y + c.size.height);
|
||||
}
|
||||
return { width: Math.max(maxX + 48, 800), height: Math.max(maxY + 48, 400) };
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Preview(): React.ReactElement {
|
||||
const { doc, projectName } = useProject();
|
||||
const pages = doc.project.pages;
|
||||
|
||||
// Page selection (tabs when multiple pages; single page for MVP)
|
||||
const [activePageIndex, setActivePageIndex] = React.useState(0);
|
||||
const activePage = pages[activePageIndex] ?? null;
|
||||
|
||||
const bounds = activePage ? canvasBounds(activePage.components) : { width: 800, height: 400 };
|
||||
|
||||
// ── Preview runtime (binding execution, component state) ─────────────────
|
||||
const runtime = usePreviewRuntime(doc);
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
|
||||
{/* ── Preview header ────────────────────────────────────────── */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<span className={styles.badge}>Preview</span>
|
||||
<span className={styles.projectName}>{projectName}</span>
|
||||
</div>
|
||||
<span className={styles.hint}>
|
||||
Read-only render of the current project. Switch to Visual Editor or JSON Editor to make changes.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Page tabs (shown only when more than one page exists) ─── */}
|
||||
{pages.length > 1 && (
|
||||
<div className={styles.tabs}>
|
||||
{pages.map((page, i) => (
|
||||
<button
|
||||
key={page.id}
|
||||
className={[styles.tab, i === activePageIndex ? styles.tabActive : ''].join(' ')}
|
||||
onClick={() => setActivePageIndex(i)}
|
||||
>
|
||||
{page.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Canvas area ───────────────────────────────────────────── */}
|
||||
<div className={styles.canvasScroll}>
|
||||
{!activePage || activePage.components.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyTitle}>Nothing to preview</p>
|
||||
<p className={styles.emptyHint}>
|
||||
Add components in the Visual Editor or JSON Editor, then return here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{ width: bounds.width, height: bounds.height }}
|
||||
>
|
||||
{activePage.components.map((c) => (
|
||||
<PreviewComponent
|
||||
key={c.id}
|
||||
component={c}
|
||||
runtimeState={runtime.componentState[c.id]}
|
||||
isLoading={runtime.buttonLoading[c.id] === true}
|
||||
onButtonClick={runtime.handleButtonClick}
|
||||
onTextInputChange={runtime.handleTextInputChange}
|
||||
onDropdownChange={runtime.handleDropdownChange}
|
||||
onTableRowSelect={runtime.handleTableRowSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Preview;
|
||||
329
frontend/src/components/Preview/PreviewComponent.module.css
Normal file
329
frontend/src/components/Preview/PreviewComponent.module.css
Normal file
@ -0,0 +1,329 @@
|
||||
/* ── Positioned wrapper (matches canvas coordinate system) ────────── */
|
||||
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Label ────────────────────────────────────────────────────────── */
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #1f2328;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Button ───────────────────────────────────────────────────────── */
|
||||
|
||||
.buttonWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 7px 18px;
|
||||
background: #3b82d4;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, box-shadow 0.12s;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: #2563b0;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
background: #1d4ed8;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #93b7e8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.buttonError {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Text input ───────────────────────────────────────────────────── */
|
||||
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
color: #1f2328;
|
||||
background: #ffffff;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82d4;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 212, 0.15);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
background: #f7f8fa;
|
||||
color: #8b949e;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Spinner (shared by button + json viewer) ─────────────────────── */
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── JSON Viewer ──────────────────────────────────────────────────── */
|
||||
|
||||
.jsonViewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.jsonPre {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #1f2328;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 5px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jsonStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
padding: 10px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.jsonErrorPre {
|
||||
margin: 0;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* ── Dropdown ─────────────────────────────────────────────────────── */
|
||||
|
||||
.dropdownWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdownLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
color: #1f2328;
|
||||
background: #ffffff;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.dropdown:focus {
|
||||
outline: none;
|
||||
border-color: #3b82d4;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 212, 0.15);
|
||||
}
|
||||
|
||||
.dropdown:disabled {
|
||||
background: #f7f8fa;
|
||||
color: #8b949e;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
/* ── Table ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.tableDisabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tableLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.tableTh {
|
||||
background: #f6f8fa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 6px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
color: #57606a;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.tableTh:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tableRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tableRowClickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tableRowClickable:hover {
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.tableRowSelected {
|
||||
background: #dbeafe !important;
|
||||
}
|
||||
|
||||
.tableTd {
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tableTd:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
422
frontend/src/components/Preview/PreviewComponent.tsx
Normal file
422
frontend/src/components/Preview/PreviewComponent.tsx
Normal file
@ -0,0 +1,422 @@
|
||||
import React from 'react';
|
||||
import type { CanvasComponent, DropdownOption, TableColumn, TableRow } from '../../types/project';
|
||||
import type { ComponentRuntimeState } from './usePreviewRuntime';
|
||||
import { labelDisplayValue } from './bindingUtils';
|
||||
import styles from './PreviewComponent.module.css';
|
||||
|
||||
// ── Per-type renderers ────────────────────────────────────────────────────────
|
||||
|
||||
type LabelRendererProps = {
|
||||
/** Configured/design-time label text from component properties. */
|
||||
configuredLabel: string;
|
||||
/** Runtime state — a defined value overrides configuredLabel. */
|
||||
runtimeState?: ComponentRuntimeState;
|
||||
};
|
||||
|
||||
function LabelRenderer({ configuredLabel, runtimeState }: LabelRendererProps): React.ReactElement {
|
||||
// A defined runtime value overrides the configured label.
|
||||
// Falsy runtime values (false, 0, "", null) must still override — only
|
||||
// undefined means "no runtime value set".
|
||||
const display =
|
||||
runtimeState?.value !== undefined
|
||||
? labelDisplayValue(runtimeState.value)
|
||||
: configuredLabel;
|
||||
|
||||
return <span className={styles.label}>{display || '\u00a0'}</span>;
|
||||
}
|
||||
|
||||
type ButtonRendererProps = {
|
||||
label: string;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ButtonRenderer({ label, isLoading, error, onClick }: ButtonRendererProps): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.buttonWrapper}>
|
||||
<button
|
||||
className={styles.button}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isLoading ? <span className={styles.spinner} aria-hidden="true" /> : null}
|
||||
{isLoading ? 'Running…' : (label || 'Button')}
|
||||
</button>
|
||||
{error && (
|
||||
<div className={styles.buttonError} role="alert" title={error}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInputRenderer({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.inputWrapper}>
|
||||
{label && <label className={styles.inputLabel}>{label}</label>}
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dropdown renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
type DropdownRendererProps = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
options: DropdownOption[];
|
||||
/** Current selection: runtimeState.value if set, else configured properties.value */
|
||||
selectedValue: string;
|
||||
disabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function DropdownRenderer({
|
||||
label,
|
||||
placeholder,
|
||||
options,
|
||||
selectedValue,
|
||||
disabled,
|
||||
onChange,
|
||||
}: DropdownRendererProps): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.dropdownWrapper}>
|
||||
{label && <label className={styles.dropdownLabel}>{label}</label>}
|
||||
<select
|
||||
className={styles.dropdown}
|
||||
value={selectedValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{/* Disabled placeholder option — shown when no value selected */}
|
||||
<option value="" disabled>
|
||||
{placeholder || 'Select an option'}
|
||||
</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table renderer ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Converts a cell value to a display string per the step spec. */
|
||||
function renderTableCellValue(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '[serialisation error]';
|
||||
}
|
||||
}
|
||||
|
||||
type TableRendererProps = {
|
||||
label: string;
|
||||
columns: TableColumn[];
|
||||
rows: TableRow[];
|
||||
disabled: boolean;
|
||||
selectedIndex?: number;
|
||||
onRowSelect: (index: number, row: TableRow) => void;
|
||||
};
|
||||
|
||||
function TableRenderer({
|
||||
label,
|
||||
columns,
|
||||
rows,
|
||||
disabled,
|
||||
selectedIndex,
|
||||
onRowSelect,
|
||||
}: TableRendererProps): React.ReactElement {
|
||||
const effectiveCols: TableColumn[] =
|
||||
columns.length > 0
|
||||
? columns
|
||||
: rows.length > 0
|
||||
? Object.keys(rows[0]).map((k) => ({ key: k, header: k }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={[styles.tableWrapper, disabled ? styles.tableDisabled : ''].join(' ')}>
|
||||
{label && <div className={styles.tableLabel}>{label}</div>}
|
||||
{effectiveCols.length === 0 && rows.length === 0 ? (
|
||||
<div className={styles.tableEmpty}>No data</div>
|
||||
) : (
|
||||
<div className={styles.tableScroll}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{effectiveCols.map((col) => (
|
||||
<th key={col.key} className={styles.tableTh}>{col.header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className={styles.tableTd}
|
||||
colSpan={effectiveCols.length || 1}
|
||||
style={{ textAlign: 'center', color: '#8b949e' }}
|
||||
>
|
||||
No rows
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, ri) => (
|
||||
<tr
|
||||
key={ri}
|
||||
className={[
|
||||
styles.tableRow,
|
||||
ri === selectedIndex ? styles.tableRowSelected : '',
|
||||
disabled ? '' : styles.tableRowClickable,
|
||||
].join(' ')}
|
||||
onClick={() => { if (!disabled) onRowSelect(ri, row); }}
|
||||
>
|
||||
{effectiveCols.map((col) => (
|
||||
<td key={col.key} className={styles.tableTd}>
|
||||
{renderTableCellValue(row[col.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type JsonViewerRendererProps = {
|
||||
defaultValue: string;
|
||||
runtimeState?: ComponentRuntimeState;
|
||||
};
|
||||
|
||||
function JsonViewerRenderer({ defaultValue, runtimeState }: JsonViewerRendererProps): React.ReactElement {
|
||||
// Loading state
|
||||
if (runtimeState?.loading) {
|
||||
return (
|
||||
<div className={styles.jsonViewer}>
|
||||
<div className={styles.jsonStatus}>
|
||||
<span className={styles.spinner} aria-hidden="true" />
|
||||
Waiting for response…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (runtimeState?.error) {
|
||||
return (
|
||||
<div className={styles.jsonViewer}>
|
||||
<div className={styles.jsonError} role="alert">
|
||||
<strong>Error</strong>
|
||||
<pre className={styles.jsonErrorPre}>{runtimeState.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Runtime value from a completed action call
|
||||
if (runtimeState?.value !== undefined) {
|
||||
const display = typeof runtimeState.value === 'string'
|
||||
? runtimeState.value
|
||||
: JSON.stringify(runtimeState.value, null, 2);
|
||||
return (
|
||||
<div className={styles.jsonViewer}>
|
||||
<pre className={styles.jsonPre}>{display}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Design-time defaultValue (no action has fired yet)
|
||||
let display: string;
|
||||
if (defaultValue) {
|
||||
try {
|
||||
display = JSON.stringify(JSON.parse(defaultValue), null, 2);
|
||||
} catch {
|
||||
display = defaultValue;
|
||||
}
|
||||
} else {
|
||||
display = JSON.stringify(
|
||||
{ status: 'No data', hint: 'Click a bound button to populate this viewer.' },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.jsonViewer}>
|
||||
<pre className={styles.jsonPre}>{display}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type PreviewComponentProps = {
|
||||
component: CanvasComponent;
|
||||
runtimeState?: ComponentRuntimeState;
|
||||
isLoading: boolean;
|
||||
onButtonClick: (componentId: string) => void;
|
||||
onTextInputChange: (componentId: string, value: string) => void;
|
||||
onDropdownChange: (componentId: string, value: string) => void;
|
||||
onTableRowSelect: (componentId: string, index: number, row: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
function PreviewComponent({
|
||||
component,
|
||||
runtimeState,
|
||||
isLoading,
|
||||
onButtonClick,
|
||||
onTextInputChange,
|
||||
onDropdownChange,
|
||||
onTableRowSelect,
|
||||
}: PreviewComponentProps): React.ReactElement | null {
|
||||
const { type, position, size, properties } = component;
|
||||
|
||||
// Respect the visibility flag — hidden components are not rendered
|
||||
if (properties.visible === false) return null;
|
||||
|
||||
const label = typeof properties.label === 'string' ? properties.label : '';
|
||||
const placeholder = typeof properties.placeholder === 'string' ? properties.placeholder : '';
|
||||
const defaultValue = typeof properties.defaultValue === 'string' ? properties.defaultValue : '';
|
||||
const disabled = properties.disabled === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.wrapper}
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: size.width,
|
||||
...(type === 'TextInput' || type === 'JsonViewer' || type === 'Dropdown' || type === 'Table'
|
||||
? { minHeight: size.height }
|
||||
: { height: size.height }),
|
||||
}}
|
||||
>
|
||||
{type === 'Label' && (
|
||||
<LabelRenderer configuredLabel={label} runtimeState={runtimeState} />
|
||||
)}
|
||||
|
||||
{type === 'Button' && (
|
||||
<ButtonRenderer
|
||||
label={label}
|
||||
isLoading={isLoading}
|
||||
error={runtimeState?.error}
|
||||
onClick={() => onButtonClick(component.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'TextInput' && (
|
||||
<TextInputRenderer
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={runtimeState?.textValue ?? defaultValue}
|
||||
disabled={disabled}
|
||||
onChange={(val) => onTextInputChange(component.id, val)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'JsonViewer' && (
|
||||
<JsonViewerRenderer
|
||||
defaultValue={defaultValue}
|
||||
runtimeState={runtimeState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'Table' && (() => {
|
||||
const rawColumns = properties.columns;
|
||||
const configuredColumns: TableColumn[] = Array.isArray(rawColumns)
|
||||
? (rawColumns as TableColumn[]).filter(
|
||||
(c) => c && typeof c.key === 'string' && typeof c.header === 'string',
|
||||
)
|
||||
: [];
|
||||
const rawRows = properties.rows;
|
||||
const configuredRows: TableRow[] = Array.isArray(rawRows)
|
||||
? (rawRows as TableRow[]).filter(
|
||||
(r) => r !== null && typeof r === 'object' && !Array.isArray(r),
|
||||
)
|
||||
: [];
|
||||
// Runtime rows override configured rows when defined (including an empty array).
|
||||
// undefined means "no runtime rows yet" — fall back to configured rows.
|
||||
const effectiveRows: TableRow[] =
|
||||
runtimeState?.rows !== undefined ? runtimeState.rows : configuredRows;
|
||||
return (
|
||||
<TableRenderer
|
||||
label={label}
|
||||
columns={configuredColumns}
|
||||
rows={effectiveRows}
|
||||
disabled={disabled}
|
||||
selectedIndex={runtimeState?.selectedIndex}
|
||||
onRowSelect={(idx, row) => onTableRowSelect(component.id, idx, row)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'Dropdown' && (() => {
|
||||
// Configured options from design-time properties
|
||||
const rawConfiguredOptions = properties.options;
|
||||
const configuredOptions: DropdownOption[] = Array.isArray(rawConfiguredOptions)
|
||||
? (rawConfiguredOptions as DropdownOption[]).filter(
|
||||
(o) => o && typeof o.label === 'string' && typeof o.value === 'string',
|
||||
)
|
||||
: [];
|
||||
// Runtime options override configured options when defined.
|
||||
// An empty runtime array is meaningful — do not fall back.
|
||||
const effectiveOptions: DropdownOption[] =
|
||||
runtimeState?.options !== undefined
|
||||
? runtimeState.options
|
||||
: configuredOptions;
|
||||
// configured value from properties (string)
|
||||
const configuredValue = typeof properties.value === 'string' ? properties.value : '';
|
||||
// runtime selection overrides configured value (undefined means no runtime selection yet)
|
||||
const selectedValue =
|
||||
runtimeState?.value !== undefined
|
||||
? String(runtimeState.value)
|
||||
: configuredValue;
|
||||
return (
|
||||
<DropdownRenderer
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
options={effectiveOptions}
|
||||
selectedValue={selectedValue}
|
||||
disabled={disabled}
|
||||
onChange={(val) => onDropdownChange(component.id, val)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewComponent;
|
||||
385
frontend/src/components/Preview/bindingUtils.test.ts
Normal file
385
frontend/src/components/Preview/bindingUtils.test.ts
Normal file
@ -0,0 +1,385 @@
|
||||
/**
|
||||
* bindingUtils.test.ts — Step 17.1
|
||||
*
|
||||
* Unit tests for Conductor runtime dot-path parsing and resolution.
|
||||
* Tests cover: source parsing, source resolution (including falsy values),
|
||||
* target parsing, trigger classification, label display value, and
|
||||
* duplicate component name handling.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseActionSourcePath,
|
||||
resolveActionSource,
|
||||
parseComponentTargetPath,
|
||||
classifyTrigger,
|
||||
labelDisplayValue,
|
||||
SUPPORTED_TARGET_TYPES,
|
||||
isTargetPropertySupported,
|
||||
TRIGGER_ON_SUCCESS,
|
||||
TRIGGER_LEGACY_ON_CLICK,
|
||||
} from './bindingUtils';
|
||||
|
||||
import type { ActionRuntimeStateMap } from './bindingUtils';
|
||||
import type { ProxyResponse } from '../../api/proxyApi';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeResponse(body: unknown, ok = true): ProxyResponse {
|
||||
return { ok, status: ok ? 200 : 404, statusText: ok ? 'OK' : 'Not Found', headers: {}, body, durationMs: 42 };
|
||||
}
|
||||
|
||||
function makeActionState(actionId: string, response: ProxyResponse): ActionRuntimeStateMap {
|
||||
return { [actionId]: { response, loading: false, error: undefined } };
|
||||
}
|
||||
|
||||
// ── parseActionSourcePath ─────────────────────────────────────────────────────
|
||||
|
||||
describe('parseActionSourcePath', () => {
|
||||
test('parses "actions.<id>.response" with empty tail', () => {
|
||||
const result = parseActionSourcePath('actions.action_httpbin.response');
|
||||
expect(result).toEqual({ actionId: 'action_httpbin', tail: [] });
|
||||
});
|
||||
|
||||
test('parses "actions.<id>.response.body" with tail ["body"]', () => {
|
||||
const result = parseActionSourcePath('actions.action_httpbin.response.body');
|
||||
expect(result).toEqual({ actionId: 'action_httpbin', tail: ['body'] });
|
||||
});
|
||||
|
||||
test('parses "actions.<id>.response.body.origin"', () => {
|
||||
const result = parseActionSourcePath('actions.action_httpbin.response.body.origin');
|
||||
expect(result).toEqual({ actionId: 'action_httpbin', tail: ['body', 'origin'] });
|
||||
});
|
||||
|
||||
test('parses deeply nested path', () => {
|
||||
const result = parseActionSourcePath('actions.action_lookup.response.body.customer.name');
|
||||
expect(result).toEqual({ actionId: 'action_lookup', tail: ['body', 'customer', 'name'] });
|
||||
});
|
||||
|
||||
test('returns null for component path', () => {
|
||||
expect(parseActionSourcePath('components.input.value')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for variable path', () => {
|
||||
expect(parseActionSourcePath('variables.lastStatus')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for path without response segment', () => {
|
||||
expect(parseActionSourcePath('actions.action_httpbin.result')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for bare "actions."', () => {
|
||||
expect(parseActionSourcePath('actions.')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for empty string', () => {
|
||||
expect(parseActionSourcePath('')).toBeNull();
|
||||
});
|
||||
|
||||
test('exact action ID match — does not match prefix', () => {
|
||||
const result = parseActionSourcePath('actions.action_one.response');
|
||||
expect(result?.actionId).toBe('action_one');
|
||||
// action_one_backup would have a different parse
|
||||
const result2 = parseActionSourcePath('actions.action_one_backup.response');
|
||||
expect(result2?.actionId).toBe('action_one_backup');
|
||||
expect(result2?.actionId).not.toBe(result?.actionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveActionSource ───────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveActionSource — valid paths', () => {
|
||||
const response = makeResponse({ origin: '1.2.3.4', url: 'https://httpbin.org/get', nested: { name: 'Alice' } });
|
||||
const state = makeActionState('action_httpbin', response);
|
||||
|
||||
test('"actions.<id>.response" returns full envelope', () => {
|
||||
const parsed = parseActionSourcePath('actions.action_httpbin.response')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: response });
|
||||
});
|
||||
|
||||
test('"actions.<id>.response.body" returns body', () => {
|
||||
const parsed = parseActionSourcePath('actions.action_httpbin.response.body')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: response.body });
|
||||
});
|
||||
|
||||
test('"actions.<id>.response.body.origin" returns field', () => {
|
||||
const parsed = parseActionSourcePath('actions.action_httpbin.response.body.origin')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: '1.2.3.4' });
|
||||
});
|
||||
|
||||
test('nested field "body.nested.name"', () => {
|
||||
const parsed = parseActionSourcePath('actions.action_httpbin.response.body.nested.name')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: 'Alice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActionSource — falsy values are valid', () => {
|
||||
test('false is a valid resolved value', () => {
|
||||
const state = makeActionState('a', makeResponse({ flag: false }));
|
||||
const parsed = parseActionSourcePath('actions.a.response.body.flag')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: false });
|
||||
});
|
||||
|
||||
test('0 is a valid resolved value', () => {
|
||||
const state = makeActionState('a', makeResponse({ count: 0 }));
|
||||
const parsed = parseActionSourcePath('actions.a.response.body.count')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: 0 });
|
||||
});
|
||||
|
||||
test('"" (empty string) is a valid resolved value', () => {
|
||||
const state = makeActionState('a', makeResponse({ name: '' }));
|
||||
const parsed = parseActionSourcePath('actions.a.response.body.name')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: '' });
|
||||
});
|
||||
|
||||
test('null is a valid resolved value', () => {
|
||||
const state = makeActionState('a', makeResponse({ field: null }));
|
||||
const parsed = parseActionSourcePath('actions.a.response.body.field')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result).toEqual({ found: true, value: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActionSource — invalid / missing', () => {
|
||||
test('action not in state returns found:false', () => {
|
||||
const parsed = parseActionSourcePath('actions.missing.response')!;
|
||||
const result = resolveActionSource(parsed, {});
|
||||
expect(result.found).toBe(false);
|
||||
expect((result as { found: false; reason: string }).reason).toMatch(/missing/);
|
||||
});
|
||||
|
||||
test('action in state but no response yet returns found:false', () => {
|
||||
const state: ActionRuntimeStateMap = { action_httpbin: { loading: true } };
|
||||
const parsed = parseActionSourcePath('actions.action_httpbin.response')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
test('missing body field returns found:false', () => {
|
||||
const state = makeActionState('a', makeResponse({ name: 'Alice' }));
|
||||
const parsed = parseActionSourcePath('actions.a.response.body.missing')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result.found).toBe(false);
|
||||
expect((result as { found: false; reason: string }).reason).toMatch(/missing/);
|
||||
});
|
||||
|
||||
test('unsupported segment after "response" returns found:false', () => {
|
||||
const state = makeActionState('a', makeResponse({}));
|
||||
const parsed = parseActionSourcePath('actions.a.response.headers')!;
|
||||
// headers not yet supported
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
test('traversal into primitive returns found:false', () => {
|
||||
const state = makeActionState('a', makeResponse({ name: 'Alice' }));
|
||||
// "name" is a string, cannot traverse further
|
||||
const parsed = parseActionSourcePath('actions.a.response.body.name.first')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
test('traversal into null mid-path returns found:false', () => {
|
||||
const state = makeActionState('a', makeResponse({ data: null }));
|
||||
const parsed = parseActionSourcePath('actions.a.response.body.data.field')!;
|
||||
const result = resolveActionSource(parsed, state);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseComponentTargetPath ──────────────────────────────────────────────────
|
||||
|
||||
describe('parseComponentTargetPath', () => {
|
||||
test('parses "components.resultsViewer.value"', () => {
|
||||
expect(parseComponentTargetPath('components.resultsViewer.value'))
|
||||
.toEqual({ componentName: 'resultsViewer', property: 'value' });
|
||||
});
|
||||
|
||||
test('parses "components.originLabel.value"', () => {
|
||||
expect(parseComponentTargetPath('components.originLabel.value'))
|
||||
.toEqual({ componentName: 'originLabel', property: 'value' });
|
||||
});
|
||||
|
||||
test('returns null for "variables.lastResult"', () => {
|
||||
expect(parseComponentTargetPath('variables.lastResult')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for unsupported "components.resultsViewer.data"', () => {
|
||||
// parseComponentTargetPath returns the parsed shape regardless — the caller
|
||||
// enforces that property === "value"; the parser just parses.
|
||||
const result = parseComponentTargetPath('components.resultsViewer.data');
|
||||
expect(result).toEqual({ componentName: 'resultsViewer', property: 'data' });
|
||||
});
|
||||
|
||||
test('returns null for path with too many segments', () => {
|
||||
expect(parseComponentTargetPath('components.a.b.c')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for bare "components."', () => {
|
||||
expect(parseComponentTargetPath('components.')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── classifyTrigger ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('classifyTrigger', () => {
|
||||
test('"onSuccess" → onSuccess', () => {
|
||||
expect(classifyTrigger('onSuccess')).toBe('onSuccess');
|
||||
});
|
||||
|
||||
test('"onClick" → onClick-legacy', () => {
|
||||
expect(classifyTrigger('onClick')).toBe('onClick-legacy');
|
||||
});
|
||||
|
||||
test('"onError" → unsupported', () => {
|
||||
expect(classifyTrigger('onError')).toBe('unsupported');
|
||||
});
|
||||
|
||||
test('"onChange" → unsupported', () => {
|
||||
expect(classifyTrigger('onChange')).toBe('unsupported');
|
||||
});
|
||||
|
||||
test('undefined defaults to "onChange" → unsupported', () => {
|
||||
expect(classifyTrigger(undefined)).toBe('unsupported');
|
||||
});
|
||||
|
||||
test('TRIGGER_ON_SUCCESS constant', () => {
|
||||
expect(TRIGGER_ON_SUCCESS).toBe('onSuccess');
|
||||
});
|
||||
|
||||
test('TRIGGER_LEGACY_ON_CLICK constant', () => {
|
||||
expect(TRIGGER_LEGACY_ON_CLICK).toBe('onClick');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SUPPORTED_TARGET_TYPES (scoped to .value targets) ────────────────────────
|
||||
|
||||
describe('SUPPORTED_TARGET_TYPES', () => {
|
||||
test('JsonViewer is supported (.value)', () => {
|
||||
expect(SUPPORTED_TARGET_TYPES.has('JsonViewer')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label is supported (.value)', () => {
|
||||
expect(SUPPORTED_TARGET_TYPES.has('Label')).toBe(true);
|
||||
});
|
||||
|
||||
test('Dropdown is NOT in SUPPORTED_TARGET_TYPES (uses .options not .value)', () => {
|
||||
expect(SUPPORTED_TARGET_TYPES.has('Dropdown')).toBe(false);
|
||||
});
|
||||
|
||||
test('Button is not supported', () => {
|
||||
expect(SUPPORTED_TARGET_TYPES.has('Button')).toBe(false);
|
||||
});
|
||||
|
||||
test('TextInput is not supported', () => {
|
||||
expect(SUPPORTED_TARGET_TYPES.has('TextInput')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isTargetPropertySupported ─────────────────────────────────────────────────
|
||||
|
||||
describe('isTargetPropertySupported', () => {
|
||||
test('JsonViewer.value → true', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label.value → true', () => {
|
||||
expect(isTargetPropertySupported('Label', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Dropdown.options → true', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'options')).toBe(true);
|
||||
});
|
||||
|
||||
test('Dropdown.value → false', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('JsonViewer.options → false', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('Label.options → false', () => {
|
||||
expect(isTargetPropertySupported('Label', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('Button.value → false', () => {
|
||||
expect(isTargetPropertySupported('Button', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('Button.options → false', () => {
|
||||
expect(isTargetPropertySupported('Button', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('TextInput.value → false', () => {
|
||||
expect(isTargetPropertySupported('TextInput', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('TextInput.options → false', () => {
|
||||
expect(isTargetPropertySupported('TextInput', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown property → false', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'data')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── labelDisplayValue ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('labelDisplayValue', () => {
|
||||
test('string renders directly', () => {
|
||||
expect(labelDisplayValue('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
test('empty string renders as empty string', () => {
|
||||
expect(labelDisplayValue('')).toBe('');
|
||||
});
|
||||
|
||||
test('number converts with String()', () => {
|
||||
expect(labelDisplayValue(42)).toBe('42');
|
||||
expect(labelDisplayValue(0)).toBe('0');
|
||||
});
|
||||
|
||||
test('boolean converts with String()', () => {
|
||||
expect(labelDisplayValue(true)).toBe('true');
|
||||
expect(labelDisplayValue(false)).toBe('false');
|
||||
});
|
||||
|
||||
test('null renders as "null"', () => {
|
||||
expect(labelDisplayValue(null)).toBe('null');
|
||||
});
|
||||
|
||||
test('object renders as compact JSON', () => {
|
||||
expect(labelDisplayValue({ a: 1 })).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
test('array renders as compact JSON', () => {
|
||||
expect(labelDisplayValue([1, 2, 3])).toBe('[1,2,3]');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Trigger semantics — onSuccess vs ok flag ──────────────────────────────────
|
||||
|
||||
describe('trigger: onSuccess fires only when proxyResponse.ok === true', () => {
|
||||
// This is exercised via classifyTrigger + the applyResponseBindings logic in
|
||||
// usePreviewRuntime. We verify here that the runtime state shape passes
|
||||
// through correctly.
|
||||
|
||||
test('ok:true response — action state records success', () => {
|
||||
const successResponse = makeResponse({ data: 'hi' }, true);
|
||||
const state = makeActionState('a', successResponse);
|
||||
expect(state['a'].response?.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('ok:false response — action state records failure', () => {
|
||||
const failResponse = makeResponse({ error: 'not found' }, false);
|
||||
const state = makeActionState('a', failResponse);
|
||||
expect(state['a'].response?.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
588
frontend/src/components/Preview/bindingUtils.ts
Normal file
588
frontend/src/components/Preview/bindingUtils.ts
Normal file
@ -0,0 +1,588 @@
|
||||
/**
|
||||
* bindingUtils — Step 17.3 / 17.5 / 18.1
|
||||
*
|
||||
* Pure utilities for Conductor runtime dot-path parsing and resolution.
|
||||
* Used by both the Preview runtime (usePreviewRuntime) and the
|
||||
* Actions & Bindings Inspector (ActionInspector) for diagnostics.
|
||||
*
|
||||
* ── Supported source grammar ──────────────────────────────────────────────────
|
||||
*
|
||||
* actions.<actionId>.response
|
||||
* actions.<actionId>.response.body
|
||||
* actions.<actionId>.response.body.<field>
|
||||
* actions.<actionId>.response.body.<nested>.<field>
|
||||
*
|
||||
* This is a Conductor runtime dot path, not JSONPath.
|
||||
*
|
||||
* ── Supported target grammar ──────────────────────────────────────────────────
|
||||
*
|
||||
* components.<componentName>.value → JsonViewer, Label
|
||||
* components.<componentName>.options → Dropdown only
|
||||
* components.<componentName>.rows → Table only (Step 17.5)
|
||||
* variables.<variableName> → runtime variable (Step 18.1)
|
||||
*
|
||||
* ── Supported triggers ───────────────────────────────────────────────────────
|
||||
*
|
||||
* onSuccess — canonical (Step 17.1+)
|
||||
* onClick — legacy Step 15 compatibility
|
||||
*
|
||||
* ── Not supported ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Wildcards, filters, array transforms, JSONPath, bracket notation,
|
||||
* keys containing literal dots, component-change bindings.
|
||||
*/
|
||||
|
||||
import type { ProxyResponse } from '../../api/proxyApi';
|
||||
import type { DropdownOption, TableRow } from '../../types/project';
|
||||
|
||||
// ── Action runtime state ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ephemeral runtime state for a single REST action.
|
||||
* Stored in Preview runtime; never written to the canonical project document.
|
||||
*/
|
||||
export type ActionRuntimeState = {
|
||||
/** The full ProxyResponse envelope from the most recent execution. */
|
||||
response?: ProxyResponse;
|
||||
/** True while the action is in-flight. */
|
||||
loading?: boolean;
|
||||
/** Human-readable error from the last failed execution. */
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Map keyed by action ID. */
|
||||
export type ActionRuntimeStateMap = Record<string, ActionRuntimeState>;
|
||||
|
||||
// ── Target capability model ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Component types that may receive a runtime `.value` update via response bindings.
|
||||
* Scoped to the `.value` property only.
|
||||
* Dropdown is intentionally excluded — it uses `.options` instead.
|
||||
*/
|
||||
export const SUPPORTED_TARGET_TYPES = new Set(['JsonViewer', 'Label']);
|
||||
|
||||
/**
|
||||
* Property-aware target capability check.
|
||||
*
|
||||
* Returns true when the given component type supports the given target property:
|
||||
* .value → JsonViewer, Label
|
||||
* .options → Dropdown
|
||||
* .rows → Table
|
||||
*
|
||||
* All other combinations return false.
|
||||
*/
|
||||
export function isTargetPropertySupported(componentType: string, property: string): boolean {
|
||||
if (property === 'value') return SUPPORTED_TARGET_TYPES.has(componentType);
|
||||
if (property === 'options') return componentType === 'Dropdown';
|
||||
if (property === 'rows') return componentType === 'Table';
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Trigger constants ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Canonical trigger for action-response bindings (Step 17.1+). */
|
||||
export const TRIGGER_ON_SUCCESS = 'onSuccess';
|
||||
|
||||
/** Legacy trigger accepted for backward compatibility with Step 15 examples. */
|
||||
export const TRIGGER_LEGACY_ON_CLICK = 'onClick';
|
||||
|
||||
// ── Source path parsing ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed representation of an action-response source path.
|
||||
* Null when the expression is not an action-response path.
|
||||
*/
|
||||
export type ParsedActionSource = {
|
||||
/** The action ID extracted from the path. */
|
||||
actionId: string;
|
||||
/**
|
||||
* Remaining segments after "response", split on ".".
|
||||
* Empty array means the path is exactly "actions.<id>.response".
|
||||
* ["body"] means ".response.body".
|
||||
* ["body", "origin"] means ".response.body.origin".
|
||||
*/
|
||||
tail: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses an action-response source expression.
|
||||
*
|
||||
* Valid forms:
|
||||
* actions.<id>.response
|
||||
* actions.<id>.response.body
|
||||
* actions.<id>.response.body.<field...>
|
||||
*
|
||||
* Returns null for anything else (component paths, variables, malformed).
|
||||
*/
|
||||
export function parseActionSourcePath(expr: string): ParsedActionSource | null {
|
||||
// Must start with "actions."
|
||||
if (!expr.startsWith('actions.')) return null;
|
||||
|
||||
// Split on "." — expect at minimum ["actions", "<id>", "response"]
|
||||
const parts = expr.split('.');
|
||||
|
||||
if (parts.length < 3) return null;
|
||||
if (parts[0] !== 'actions') return null;
|
||||
|
||||
const actionId = parts[1];
|
||||
if (!actionId) return null;
|
||||
|
||||
if (parts[2] !== 'response') return null;
|
||||
|
||||
// Tail is everything after "response"
|
||||
const tail = parts.slice(3);
|
||||
|
||||
return { actionId, tail };
|
||||
}
|
||||
|
||||
// ── Source value resolution ───────────────────────────────────────────────────
|
||||
|
||||
/** Sentinel distinguishing "field exists with value undefined" (unusual) from
|
||||
* "path traversal failed" (field genuinely absent). We use a typed result. */
|
||||
export type SourceResolveResult =
|
||||
| { found: true; value: unknown }
|
||||
| { found: false; reason: string };
|
||||
|
||||
/**
|
||||
* Resolves a parsed action source against the runtime action state.
|
||||
*
|
||||
* Traversal rules:
|
||||
* - Falsy values (false, 0, "", null) are valid resolved values.
|
||||
* - Missing fields return { found: false }.
|
||||
* - Traversal into a non-object/non-null mid-path returns { found: false }.
|
||||
*/
|
||||
export function resolveActionSource(
|
||||
parsed: ParsedActionSource,
|
||||
actionState: ActionRuntimeStateMap,
|
||||
): SourceResolveResult {
|
||||
const state = actionState[parsed.actionId];
|
||||
if (!state) {
|
||||
return {
|
||||
found: false,
|
||||
reason: `Action "${parsed.actionId}" has no runtime state. Has the action been executed?`,
|
||||
};
|
||||
}
|
||||
if (state.response === undefined) {
|
||||
return {
|
||||
found: false,
|
||||
reason: `Action "${parsed.actionId}" has no response yet.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Path is exactly "actions.<id>.response" — return full envelope
|
||||
if (parsed.tail.length === 0) {
|
||||
return { found: true, value: state.response };
|
||||
}
|
||||
|
||||
// Next segment must be "body"
|
||||
if (parsed.tail[0] !== 'body') {
|
||||
return {
|
||||
found: false,
|
||||
reason:
|
||||
`Unsupported source path segment "${parsed.tail[0]}" after "response". ` +
|
||||
`Supported forms: response, response.body, response.body.<field…>`,
|
||||
};
|
||||
}
|
||||
|
||||
// Path is "actions.<id>.response.body"
|
||||
if (parsed.tail.length === 1) {
|
||||
return { found: true, value: state.response.body };
|
||||
}
|
||||
|
||||
// Traverse remaining segments into body
|
||||
const fieldPath = parsed.tail.slice(1);
|
||||
let current: unknown = state.response.body;
|
||||
|
||||
for (let i = 0; i < fieldPath.length; i++) {
|
||||
const segment = fieldPath[i];
|
||||
|
||||
if (current === null || current === undefined) {
|
||||
const traversed = ['response', 'body', ...fieldPath.slice(0, i)].join('.');
|
||||
return {
|
||||
found: false,
|
||||
reason:
|
||||
`Cannot read "${segment}" — value at "${traversed}" is ` +
|
||||
`${current === null ? 'null' : 'undefined'}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof current !== 'object' || Array.isArray(current)) {
|
||||
const traversed = ['response', 'body', ...fieldPath.slice(0, i)].join('.');
|
||||
return {
|
||||
found: false,
|
||||
reason:
|
||||
`Cannot traverse into "${traversed}" — it is ` +
|
||||
`${Array.isArray(current) ? 'an array' : `a ${typeof current}`}, not an object.`,
|
||||
};
|
||||
}
|
||||
|
||||
const obj = current as Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, segment)) {
|
||||
const traversed = ['response', 'body', ...fieldPath.slice(0, i + 1)].join('.');
|
||||
return {
|
||||
found: false,
|
||||
reason: `Field "${traversed}" does not exist in the action response body.`,
|
||||
};
|
||||
}
|
||||
|
||||
current = obj[segment];
|
||||
}
|
||||
|
||||
return { found: true, value: current };
|
||||
}
|
||||
|
||||
// ── Target path parsing ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed representation of a component target path.
|
||||
*/
|
||||
export type ParsedComponentTarget = {
|
||||
componentName: string;
|
||||
property: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a component target expression.
|
||||
*
|
||||
* Valid forms:
|
||||
* components.<name>.value
|
||||
* components.<name>.options
|
||||
* components.<name>.rows
|
||||
*
|
||||
* Returns null for anything else (variables, malformed, missing segments).
|
||||
*/
|
||||
export function parseComponentTargetPath(expr: string): ParsedComponentTarget | null {
|
||||
if (!expr.startsWith('components.')) return null;
|
||||
|
||||
const parts = expr.split('.');
|
||||
// Expect exactly ["components", "<name>", "<property>"]
|
||||
if (parts.length !== 3) return null;
|
||||
if (!parts[1] || !parts[2]) return null;
|
||||
|
||||
return { componentName: parts[1], property: parts[2] };
|
||||
}
|
||||
|
||||
// ── Variable target path parsing (Step 18.1) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed representation of a variable target path.
|
||||
*/
|
||||
export type ParsedVariableTarget = {
|
||||
variableName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a variable target expression.
|
||||
*
|
||||
* Valid form:
|
||||
* variables.<name>
|
||||
*
|
||||
* Returns null for anything else (components, malformed, missing segments).
|
||||
* The variable name must be a non-empty string with no embedded dots.
|
||||
*/
|
||||
export function parseVariableTargetPath(expr: string): ParsedVariableTarget | null {
|
||||
if (!expr.startsWith('variables.')) return null;
|
||||
|
||||
// Expect exactly "variables.<name>" — one dot, non-empty name, no further dots
|
||||
const rest = expr.slice('variables.'.length);
|
||||
if (!rest || rest.includes('.')) return null;
|
||||
|
||||
return { variableName: rest };
|
||||
}
|
||||
|
||||
// ── Trigger classification ────────────────────────────────────────────────────
|
||||
|
||||
export type TriggerClassification =
|
||||
| 'onSuccess' // canonical
|
||||
| 'onClick-legacy' // Step 15 compat
|
||||
| 'unsupported'; // everything else
|
||||
|
||||
/**
|
||||
* Classifies a binding trigger in the context of action-response bindings.
|
||||
* Returns 'onSuccess', 'onClick-legacy', or 'unsupported'.
|
||||
*/
|
||||
export function classifyTrigger(trigger: string | undefined): TriggerClassification {
|
||||
const t = trigger ?? 'onChange';
|
||||
if (t === TRIGGER_ON_SUCCESS) return 'onSuccess';
|
||||
if (t === TRIGGER_LEGACY_ON_CLICK) return 'onClick-legacy';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// ── Dropdown option normalisation ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of normalising a raw response-array value into DropdownOption[].
|
||||
*/
|
||||
export type NormalizeOptionsResult =
|
||||
| { ok: true; options: DropdownOption[]; warnings: string[] }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Normalises a raw value from an action response into an array of DropdownOption.
|
||||
*
|
||||
* Accepted input shapes:
|
||||
* Shape A — array of strings: ["host1", "host2"]
|
||||
* Shape B — array of {label: string, value: string} objects
|
||||
* (extra fields on objects are ignored)
|
||||
*
|
||||
* Rejected shapes (returns ok:false):
|
||||
* - Not an array
|
||||
* - Mixed array (strings and objects together)
|
||||
* - Array containing numbers, booleans, null
|
||||
* - Array of objects missing string `label`
|
||||
* - Array of objects missing string `value`
|
||||
* - Array of nested option structures (arrays inside)
|
||||
*
|
||||
* Empty array is valid — produces ok:true with options:[].
|
||||
*
|
||||
* Warnings (ok:true, warnings non-empty):
|
||||
* - Duplicate option values
|
||||
* - Empty label string
|
||||
* - Empty value string
|
||||
*
|
||||
* Does not auto-convert numbers or booleans to strings.
|
||||
* Does not guess fields like name, id, title, key, displayName.
|
||||
*/
|
||||
export function normalizeDropdownOptions(raw: unknown): NormalizeOptionsResult {
|
||||
if (!Array.isArray(raw)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Expected an array of strings or label/value objects, but got ` +
|
||||
`${raw === null ? 'null' : typeof raw}. ` +
|
||||
`Source must resolve to an array before options can be applied.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (raw.length === 0) {
|
||||
return { ok: true, options: [], warnings: [] };
|
||||
}
|
||||
|
||||
// Detect the shape from the first element
|
||||
const first = raw[0];
|
||||
const isStringArray = typeof first === 'string';
|
||||
const isObjectArray = typeof first === 'object' && first !== null && !Array.isArray(first);
|
||||
|
||||
if (!isStringArray && !isObjectArray) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Array element at index 0 has unsupported type ` +
|
||||
`${first === null ? 'null' : Array.isArray(first) ? 'array' : typeof first}. ` +
|
||||
`Only arrays of strings or label/value objects are supported.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate each element for consistency and correctness
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const el = raw[i];
|
||||
|
||||
if (isStringArray) {
|
||||
// All elements must be strings
|
||||
if (typeof el !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Mixed array: element at index 0 is a string but element at index ${i} is ` +
|
||||
`${el === null ? 'null' : Array.isArray(el) ? 'an array' : `a ${typeof el}`}. ` +
|
||||
`Do not partially apply valid elements from a mixed array.`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// All elements must be plain objects with string label and string value
|
||||
if (typeof el !== 'object' || el === null || Array.isArray(el)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Mixed array: element at index 0 is an object but element at index ${i} is ` +
|
||||
`${el === null ? 'null' : Array.isArray(el) ? 'an array' : `a ${typeof el}`}. ` +
|
||||
`Do not partially apply valid elements from a mixed array.`,
|
||||
};
|
||||
}
|
||||
const obj = el as Record<string, unknown>;
|
||||
if (typeof obj['label'] !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Object at index ${i} is missing a string "label" field ` +
|
||||
`(found: ${obj['label'] === undefined ? 'undefined' : `${typeof obj['label']} "${obj['label']}"`}). ` +
|
||||
`Expected: { "label": string, "value": string }.`,
|
||||
};
|
||||
}
|
||||
if (typeof obj['value'] !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Object at index ${i} is missing a string "value" field ` +
|
||||
`(found: ${obj['value'] === undefined ? 'undefined' : `${typeof obj['value']} "${obj['value']}"`}). ` +
|
||||
`Expected: { "label": string, "value": string }.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All elements valid — build normalized options (new objects, no response references retained)
|
||||
const options: DropdownOption[] = isStringArray
|
||||
? (raw as string[]).map((s) => ({ label: s, value: s }))
|
||||
: (raw as Array<Record<string, unknown>>).map((obj) => ({
|
||||
label: obj['label'] as string,
|
||||
value: obj['value'] as string,
|
||||
}));
|
||||
|
||||
// Collect warnings for empty labels/values and duplicate values
|
||||
const warnings: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
const dupValues = new Set<string>();
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const opt = options[i];
|
||||
if (!opt.label) {
|
||||
warnings.push(`Option at index ${i} has an empty label.`);
|
||||
}
|
||||
if (!opt.value) {
|
||||
warnings.push(`Option at index ${i} has an empty value.`);
|
||||
}
|
||||
if (opt.value) {
|
||||
if (seenValues.has(opt.value)) dupValues.add(opt.value);
|
||||
seenValues.add(opt.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (dupValues.size > 0) {
|
||||
warnings.push(
|
||||
`Duplicate option values detected: ${[...dupValues].map((v) => `"${v}"`).join(', ')}. ` +
|
||||
`Runtime selection may be ambiguous.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, options, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the current Dropdown selection after runtime options are replaced.
|
||||
*
|
||||
* Effective current value (in priority order):
|
||||
* 1. runtimeValue if defined
|
||||
* 2. configuredValue if non-empty
|
||||
* 3. ""
|
||||
*
|
||||
* If the effective value exists in the new options, it is preserved.
|
||||
* If it does not exist, returns "".
|
||||
* The first option is never auto-selected.
|
||||
* An empty options array always clears the selection.
|
||||
*/
|
||||
export function reconcileDropdownSelection(
|
||||
runtimeValue: unknown,
|
||||
configuredValue: string,
|
||||
newOptions: DropdownOption[],
|
||||
): string {
|
||||
const effective =
|
||||
runtimeValue !== undefined
|
||||
? String(runtimeValue)
|
||||
: configuredValue !== ''
|
||||
? configuredValue
|
||||
: '';
|
||||
|
||||
if (effective === '') return '';
|
||||
if (newOptions.some((o) => o.value === effective)) return effective;
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Label value serialisation ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a runtime binding value to a display string for Label components.
|
||||
*
|
||||
* Rules:
|
||||
* string → render directly
|
||||
* number | boolean → String(value)
|
||||
* null → "null"
|
||||
* object | array → compact JSON.stringify
|
||||
* serialisation fails → "[object: serialisation error]"
|
||||
*/
|
||||
export function labelDisplayValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (value === null) return 'null';
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '[object: serialisation error]';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table row normalization ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of normalising a raw response-array value into TableRow[].
|
||||
*/
|
||||
export type NormalizeTableRowsResult =
|
||||
| { ok: true; rows: TableRow[] }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Normalises a raw value from an action response into an array of TableRow.
|
||||
*
|
||||
* Accepted input:
|
||||
* Array<Record<string, unknown>>
|
||||
* - every element must be a non-null, non-array object
|
||||
* - empty array is valid
|
||||
* - elements may contain strings, numbers, booleans, null, nested objects/arrays
|
||||
*
|
||||
* Rejected shapes (returns ok:false):
|
||||
* - not an array
|
||||
* - array of strings / numbers / booleans
|
||||
* - array containing null
|
||||
* - array containing nested arrays
|
||||
* - mixed object/non-object array
|
||||
*
|
||||
* On acceptance:
|
||||
* - row order is preserved
|
||||
* - shallow copies of each row object are created (no retained response references)
|
||||
*
|
||||
* Does not partially apply valid rows from an invalid array.
|
||||
*/
|
||||
export function normalizeTableRows(raw: unknown): NormalizeTableRowsResult {
|
||||
if (!Array.isArray(raw)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Expected an array of objects, but got ` +
|
||||
`${raw === null ? 'null' : typeof raw}. ` +
|
||||
`Source must resolve to an array of objects before rows can be applied.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (raw.length === 0) {
|
||||
return { ok: true, rows: [] };
|
||||
}
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const el = raw[i];
|
||||
if (el === null) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Array element at index ${i} is null. Table rows must be non-null objects.`,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(el)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Array element at index ${i} is an array. Table rows must be non-array objects.`,
|
||||
};
|
||||
}
|
||||
if (typeof el !== 'object') {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`Array element at index ${i} has unsupported type ${typeof el}. ` +
|
||||
`Table rows must be plain objects.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All elements valid — create shallow copies (no retained response references)
|
||||
const rows: TableRow[] = (raw as Record<string, unknown>[]).map((el) => ({ ...el }));
|
||||
return { ok: true, rows };
|
||||
}
|
||||
448
frontend/src/components/Preview/dropdown.test.ts
Normal file
448
frontend/src/components/Preview/dropdown.test.ts
Normal file
@ -0,0 +1,448 @@
|
||||
/**
|
||||
* dropdown.test.ts — Step 17.2
|
||||
*
|
||||
* Tests for Dropdown component: option handling, runtime selection state,
|
||||
* template resolution, Inspector diagnostics, and Step 17.1 regression.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseActionSourcePath,
|
||||
classifyTrigger,
|
||||
SUPPORTED_TARGET_TYPES,
|
||||
isTargetPropertySupported,
|
||||
} from './bindingUtils';
|
||||
|
||||
import type { ActionRuntimeStateMap } from './bindingUtils';
|
||||
import type { ProxyResponse } from '../../api/proxyApi';
|
||||
import type { DropdownOption } from '../../types/project';
|
||||
import { interpolateString } from './templateUtils';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeResponse(body: unknown): ProxyResponse {
|
||||
return { ok: true, status: 200, statusText: 'OK', headers: {}, body, durationMs: 10 };
|
||||
}
|
||||
|
||||
// ── Option type contract ──────────────────────────────────────────────────────
|
||||
|
||||
describe('DropdownOption shape', () => {
|
||||
test('valid option has string label and string value', () => {
|
||||
const opt: DropdownOption = { label: 'Development', value: 'dev' };
|
||||
expect(opt.label).toBe('Development');
|
||||
expect(opt.value).toBe('dev');
|
||||
});
|
||||
|
||||
test('empty option list is valid', () => {
|
||||
const options: DropdownOption[] = [];
|
||||
expect(options).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('duplicate values are detected', () => {
|
||||
const options: DropdownOption[] = [
|
||||
{ label: 'A', value: 'x' },
|
||||
{ label: 'B', value: 'x' },
|
||||
];
|
||||
const values = options.map((o) => o.value);
|
||||
const hasDups = values.length !== new Set(values).size;
|
||||
expect(hasDups).toBe(true);
|
||||
});
|
||||
|
||||
test('no duplicates when all values unique', () => {
|
||||
const options: DropdownOption[] = [
|
||||
{ label: 'Dev', value: 'dev' },
|
||||
{ label: 'Test', value: 'test' },
|
||||
{ label: 'Prod', value: 'prod' },
|
||||
];
|
||||
const values = options.map((o) => o.value);
|
||||
const hasDups = values.length !== new Set(values).size;
|
||||
expect(hasDups).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Configured/default value validation ──────────────────────────────────────
|
||||
|
||||
describe('Dropdown configured value', () => {
|
||||
const options: DropdownOption[] = [
|
||||
{ label: 'Dev', value: 'dev' },
|
||||
{ label: 'Prod', value: 'prod' },
|
||||
];
|
||||
|
||||
test('configured value found in options → valid', () => {
|
||||
const configuredValue = 'dev';
|
||||
const valid = options.some((o) => o.value === configuredValue);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
test('configured value not in options → invalid', () => {
|
||||
const configuredValue = 'staging';
|
||||
const valid = options.some((o) => o.value === configuredValue);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
test('empty configured value → no warning (means unset)', () => {
|
||||
const configuredValue = '';
|
||||
// Empty string means no configured selection — not a validation error
|
||||
const shouldWarn = configuredValue !== '' && !options.some((o) => o.value === configuredValue);
|
||||
expect(shouldWarn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Runtime selection — template interpolation ────────────────────────────────
|
||||
|
||||
describe('Template interpolation — Dropdown selected value', () => {
|
||||
const componentsByName = new Map([['environmentDropdown', 'dropdown_env_1']]);
|
||||
|
||||
test('selected Dropdown value resolves via runtimeState.value', () => {
|
||||
const componentState = {
|
||||
dropdown_env_1: { value: 'prod' },
|
||||
};
|
||||
const result = interpolateString(
|
||||
'{{components.environmentDropdown.value}}',
|
||||
componentsByName,
|
||||
componentState,
|
||||
);
|
||||
expect(result).toBe('prod');
|
||||
});
|
||||
|
||||
test('unselected Dropdown (empty value) resolves to empty string', () => {
|
||||
const componentState = {
|
||||
dropdown_env_1: { value: '' },
|
||||
};
|
||||
const result = interpolateString(
|
||||
'{{components.environmentDropdown.value}}',
|
||||
componentsByName,
|
||||
componentState,
|
||||
);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('no runtime state → resolves to empty string', () => {
|
||||
const componentState = {};
|
||||
const result = interpolateString(
|
||||
'{{components.environmentDropdown.value}}',
|
||||
componentsByName,
|
||||
componentState,
|
||||
);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('TextInput textValue still resolves correctly (regression)', () => {
|
||||
const componentState = {
|
||||
dropdown_env_1: { textValue: 'typed' },
|
||||
};
|
||||
const result = interpolateString(
|
||||
'{{components.environmentDropdown.value}}',
|
||||
componentsByName,
|
||||
componentState,
|
||||
);
|
||||
// textValue takes priority over value per resolution order
|
||||
expect(result).toBe('typed');
|
||||
});
|
||||
|
||||
test('Dropdown value does not leak into TextInput resolution', () => {
|
||||
const textByName = new Map([['hostnameInput', 'input_1']]);
|
||||
const componentState = {
|
||||
input_1: { textValue: 'my-host' },
|
||||
};
|
||||
const result = interpolateString(
|
||||
'{{components.hostnameInput.value}}',
|
||||
textByName,
|
||||
componentState,
|
||||
);
|
||||
expect(result).toBe('my-host');
|
||||
});
|
||||
|
||||
test('missing component throws', () => {
|
||||
expect(() =>
|
||||
interpolateString(
|
||||
'{{components.missingDropdown.value}}',
|
||||
componentsByName,
|
||||
{},
|
||||
),
|
||||
).toThrow(/missingDropdown/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Target capability model (Step 17.3) ──────────────────────────────────────
|
||||
|
||||
describe('SUPPORTED_TARGET_TYPES — .value targets only', () => {
|
||||
test('Dropdown is NOT in SUPPORTED_TARGET_TYPES (.value)', () => {
|
||||
// Dropdown is not a .value target — it uses .options
|
||||
expect(SUPPORTED_TARGET_TYPES.has('Dropdown')).toBe(false);
|
||||
});
|
||||
|
||||
test('JsonViewer is in SUPPORTED_TARGET_TYPES', () => {
|
||||
expect(SUPPORTED_TARGET_TYPES.has('JsonViewer')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label is in SUPPORTED_TARGET_TYPES', () => {
|
||||
expect(SUPPORTED_TARGET_TYPES.has('Label')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTargetPropertySupported — property-aware capability check', () => {
|
||||
// .value targets
|
||||
test('Dropdown.value → not supported', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('JsonViewer.value → supported', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label.value → supported', () => {
|
||||
expect(isTargetPropertySupported('Label', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Button.value → not supported', () => {
|
||||
expect(isTargetPropertySupported('Button', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('TextInput.value → not supported', () => {
|
||||
expect(isTargetPropertySupported('TextInput', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
// .options targets
|
||||
test('Dropdown.options → supported', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'options')).toBe(true);
|
||||
});
|
||||
|
||||
test('JsonViewer.options → not supported', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('Label.options → not supported', () => {
|
||||
expect(isTargetPropertySupported('Label', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('Button.options → not supported', () => {
|
||||
expect(isTargetPropertySupported('Button', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('TextInput.options → not supported', () => {
|
||||
expect(isTargetPropertySupported('TextInput', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
// other properties
|
||||
test('Dropdown.data → not supported', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'data')).toBe(false);
|
||||
});
|
||||
|
||||
test('JsonViewer.options → not supported (regression)', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'options')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── onChange trigger for Dropdown ─────────────────────────────────────────────
|
||||
|
||||
describe('classifyTrigger — onChange is unsupported for response bindings', () => {
|
||||
// Dropdown uses onChange component events (not bindings), so onChange
|
||||
// as a binding trigger remains unsupported — which is correct.
|
||||
test('onChange trigger → unsupported for binding trigger classification', () => {
|
||||
expect(classifyTrigger('onChange')).toBe('unsupported');
|
||||
});
|
||||
|
||||
test('onSuccess trigger → supported', () => {
|
||||
expect(classifyTrigger('onSuccess')).toBe('onSuccess');
|
||||
});
|
||||
|
||||
test('onClick trigger → legacy', () => {
|
||||
expect(classifyTrigger('onClick')).toBe('onClick-legacy');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate component name handling ────────────────────────────────────────
|
||||
|
||||
describe('Duplicate Dropdown component name ambiguity', () => {
|
||||
test('two components with same name detected as duplicate', () => {
|
||||
const components = [
|
||||
{ id: 'a', name: 'envDropdown' },
|
||||
{ id: 'b', name: 'envDropdown' },
|
||||
];
|
||||
const byName = new Map<string, typeof components>();
|
||||
for (const c of components) {
|
||||
const list = byName.get(c.name) ?? [];
|
||||
list.push(c);
|
||||
byName.set(c.name, list);
|
||||
}
|
||||
const candidates = byName.get('envDropdown') ?? [];
|
||||
expect(candidates.length).toBe(2);
|
||||
// Runtime should reject — no silent first-match
|
||||
expect(candidates.length > 1).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Action source path — Dropdown onChange action wiring ─────────────────────
|
||||
|
||||
describe('Dropdown onChange action source paths remain valid', () => {
|
||||
test('action response path still parses correctly', () => {
|
||||
const parsed = parseActionSourcePath('actions.action_httpbin_env.response.body');
|
||||
expect(parsed).toEqual({ actionId: 'action_httpbin_env', tail: ['body'] });
|
||||
});
|
||||
|
||||
test('action ID is extracted correctly for dropdown-fired action', () => {
|
||||
const parsed = parseActionSourcePath('actions.action_httpbin_env.response');
|
||||
expect(parsed?.actionId).toBe('action_httpbin_env');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Falsy value edge cases for Dropdown ──────────────────────────────────────
|
||||
|
||||
describe('Dropdown runtime value edge cases', () => {
|
||||
const byName = new Map([['myDropdown', 'dd_1']]);
|
||||
|
||||
test('value "0" resolves as string "0" via template', () => {
|
||||
const state = { dd_1: { value: 0 } };
|
||||
const result = interpolateString('{{components.myDropdown.value}}', byName, state);
|
||||
expect(result).toBe('0');
|
||||
});
|
||||
|
||||
test('null value resolves to empty string (null treated as not set)', () => {
|
||||
const state = { dd_1: { value: null } };
|
||||
// null check: if state.value !== undefined && state.value !== null → String(state.value)
|
||||
// else → ''
|
||||
const result = interpolateString('{{components.myDropdown.value}}', byName, state);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Component name / size / visible / disabled — Visual Editor controls ───────
|
||||
|
||||
describe('updateComponentName logic', () => {
|
||||
function rename(
|
||||
components: { id: string; name: string }[],
|
||||
id: string,
|
||||
newName: string,
|
||||
) {
|
||||
return components.map((c) => (c.id === id ? { ...c, name: newName } : c));
|
||||
}
|
||||
|
||||
test('renames the matching component', () => {
|
||||
const before = [{ id: 'a', name: 'button_1' }];
|
||||
const after = rename(before, 'a', 'submitBtn');
|
||||
expect(after[0].name).toBe('submitBtn');
|
||||
});
|
||||
|
||||
test('does not rename other components', () => {
|
||||
const before = [
|
||||
{ id: 'a', name: 'button_1' },
|
||||
{ id: 'b', name: 'label_1' },
|
||||
];
|
||||
const after = rename(before, 'a', 'submitBtn');
|
||||
expect(after[1].name).toBe('label_1');
|
||||
});
|
||||
|
||||
test('empty name is detectable as invalid', () => {
|
||||
const name = '';
|
||||
expect(name === '').toBe(true);
|
||||
});
|
||||
|
||||
test('duplicate name detected across components', () => {
|
||||
const components = [
|
||||
{ id: 'a', name: 'envDropdown' },
|
||||
{ id: 'b', name: 'other' },
|
||||
];
|
||||
const proposed = 'other';
|
||||
const excludeId = 'a';
|
||||
const isDup = components.some((c) => c.id !== excludeId && c.name === proposed);
|
||||
expect(isDup).toBe(true);
|
||||
});
|
||||
|
||||
test('no duplicate when name is unique', () => {
|
||||
const components = [
|
||||
{ id: 'a', name: 'envDropdown' },
|
||||
{ id: 'b', name: 'other' },
|
||||
];
|
||||
const proposed = 'newUniqueName';
|
||||
const excludeId = 'a';
|
||||
const isDup = components.some((c) => c.id !== excludeId && c.name === proposed);
|
||||
expect(isDup).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateComponentSize logic', () => {
|
||||
function resize(
|
||||
components: { id: string; size: { width: number; height: number } }[],
|
||||
id: string,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
return components.map((c) => (c.id === id ? { ...c, size: { width, height } } : c));
|
||||
}
|
||||
|
||||
test('resizes the matching component', () => {
|
||||
const before = [{ id: 'a', size: { width: 120, height: 40 } }];
|
||||
const after = resize(before, 'a', 200, 60);
|
||||
expect(after[0].size).toEqual({ width: 200, height: 60 });
|
||||
});
|
||||
|
||||
test('does not resize other components', () => {
|
||||
const before = [
|
||||
{ id: 'a', size: { width: 120, height: 40 } },
|
||||
{ id: 'b', size: { width: 240, height: 40 } },
|
||||
];
|
||||
const after = resize(before, 'a', 300, 50);
|
||||
expect(after[1].size).toEqual({ width: 240, height: 40 });
|
||||
});
|
||||
|
||||
test('negative or zero width is rejected before update', () => {
|
||||
const raw = parseInt('-10', 10);
|
||||
expect(raw > 0).toBe(false);
|
||||
});
|
||||
|
||||
test('non-numeric input is rejected before update', () => {
|
||||
const raw = parseInt('abc', 10);
|
||||
expect(isNaN(raw)).toBe(true);
|
||||
});
|
||||
|
||||
test('valid positive integer is accepted', () => {
|
||||
const raw = parseInt('200', 10);
|
||||
expect(!isNaN(raw) && raw > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visible / disabled property controls', () => {
|
||||
type Props = { visible?: unknown; disabled?: unknown };
|
||||
|
||||
test('visible defaults to true when not set (treated as !== false)', () => {
|
||||
const props: Props = {};
|
||||
expect(props.visible !== false).toBe(true);
|
||||
});
|
||||
|
||||
test('visible:false renders as hidden indicator', () => {
|
||||
const props: Props = { visible: false };
|
||||
expect(props.visible !== false).toBe(false);
|
||||
});
|
||||
|
||||
test('visible:true is explicitly on', () => {
|
||||
const props: Props = { visible: true };
|
||||
expect(props.visible !== false).toBe(true);
|
||||
});
|
||||
|
||||
test('disabled defaults to false when not set (treated as === true)', () => {
|
||||
const props: Props = {};
|
||||
expect(props.disabled === true).toBe(false);
|
||||
});
|
||||
|
||||
test('disabled:true is detectable', () => {
|
||||
const props: Props = { disabled: true };
|
||||
expect(props.disabled === true).toBe(true);
|
||||
});
|
||||
|
||||
test('disabled:false is explicitly off', () => {
|
||||
const props: Props = { disabled: false };
|
||||
expect(props.disabled === true).toBe(false);
|
||||
});
|
||||
|
||||
test('updateComponentProperty sets visible to false', () => {
|
||||
const component = { id: 'a', properties: { visible: true, disabled: false } };
|
||||
const updated = { ...component, properties: { ...component.properties, visible: false } };
|
||||
expect(updated.properties.visible).toBe(false);
|
||||
});
|
||||
|
||||
test('updateComponentProperty sets disabled to true', () => {
|
||||
const component = { id: 'a', properties: { visible: true, disabled: false } };
|
||||
const updated = { ...component, properties: { ...component.properties, disabled: true } };
|
||||
expect(updated.properties.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
373
frontend/src/components/Preview/dropdownResponseMapping.test.ts
Normal file
373
frontend/src/components/Preview/dropdownResponseMapping.test.ts
Normal file
@ -0,0 +1,373 @@
|
||||
/**
|
||||
* dropdownResponseMapping.test.ts — Step 17.3
|
||||
*
|
||||
* Focused automated tests for:
|
||||
* - normalizeDropdownOptions (normalization, rejection, warnings)
|
||||
* - reconcileDropdownSelection (selection reconciliation)
|
||||
* - isTargetPropertySupported (target capability)
|
||||
* - SUPPORTED_TARGET_TYPES regression
|
||||
*/
|
||||
|
||||
import {
|
||||
normalizeDropdownOptions,
|
||||
reconcileDropdownSelection,
|
||||
isTargetPropertySupported,
|
||||
SUPPORTED_TARGET_TYPES,
|
||||
} from './bindingUtils';
|
||||
|
||||
import type { DropdownOption } from '../../types/project';
|
||||
|
||||
// ── Normalization: Shape A — array of strings ────────────────────────────────
|
||||
|
||||
describe('normalizeDropdownOptions — Shape A: array of strings', () => {
|
||||
test('string array normalizes to label/value objects', () => {
|
||||
const result = normalizeDropdownOptions(['host1', 'host2', 'host3']);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options).toEqual([
|
||||
{ label: 'host1', value: 'host1' },
|
||||
{ label: 'host2', value: 'host2' },
|
||||
{ label: 'host3', value: 'host3' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('order is preserved', () => {
|
||||
const result = normalizeDropdownOptions(['c', 'a', 'b']);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options.map((o) => o.value)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('produces new objects, not references to input', () => {
|
||||
const input = ['host1'];
|
||||
const result = normalizeDropdownOptions(input);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options[0]).not.toBe(input[0]);
|
||||
expect(result.options[0]).toEqual({ label: 'host1', value: 'host1' });
|
||||
});
|
||||
|
||||
test('single-element string array', () => {
|
||||
const result = normalizeDropdownOptions(['only']);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options).toEqual([{ label: 'only', value: 'only' }]);
|
||||
});
|
||||
|
||||
test('no warnings for clean string array', () => {
|
||||
const result = normalizeDropdownOptions(['a', 'b', 'c']);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Normalization: Shape B — array of label/value objects ─────────────────────
|
||||
|
||||
describe('normalizeDropdownOptions — Shape B: array of label/value objects', () => {
|
||||
test('label/value objects normalize correctly', () => {
|
||||
const result = normalizeDropdownOptions([
|
||||
{ label: 'Host 1', value: 'host1' },
|
||||
{ label: 'Host 2', value: 'host2' },
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options).toEqual([
|
||||
{ label: 'Host 1', value: 'host1' },
|
||||
{ label: 'Host 2', value: 'host2' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('extra fields are ignored — only label and value are copied', () => {
|
||||
const input = [{ label: 'Host 1', value: 'host1', id: 42, status: 'active' }];
|
||||
const result = normalizeDropdownOptions(input);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options[0]).toEqual({ label: 'Host 1', value: 'host1' });
|
||||
expect((result.options[0] as Record<string, unknown>)['id']).toBeUndefined();
|
||||
expect((result.options[0] as Record<string, unknown>)['status']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('produces new objects, not references to input objects', () => {
|
||||
const inputObj = { label: 'Host 1', value: 'host1' };
|
||||
const result = normalizeDropdownOptions([inputObj]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options[0]).not.toBe(inputObj);
|
||||
expect(result.options[0]).toEqual({ label: 'Host 1', value: 'host1' });
|
||||
});
|
||||
|
||||
test('order is preserved', () => {
|
||||
const result = normalizeDropdownOptions([
|
||||
{ label: 'C', value: 'c' },
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options.map((o) => o.value)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('no warnings for clean label/value array', () => {
|
||||
const result = normalizeDropdownOptions([{ label: 'A', value: 'a' }]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Normalization: empty array ─────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeDropdownOptions — empty array', () => {
|
||||
test('empty array is valid and produces empty options', () => {
|
||||
const result = normalizeDropdownOptions([]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options).toEqual([]);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rejection: invalid input shapes ───────────────────────────────────────────
|
||||
|
||||
describe('normalizeDropdownOptions — rejection: non-array', () => {
|
||||
test('rejects non-array (object)', () => {
|
||||
const result = normalizeDropdownOptions({ hosts: ['a', 'b'] });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-array (string)', () => {
|
||||
const result = normalizeDropdownOptions('host1');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-array (number)', () => {
|
||||
const result = normalizeDropdownOptions(42);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-array (boolean)', () => {
|
||||
const result = normalizeDropdownOptions(true);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-array (null)', () => {
|
||||
const result = normalizeDropdownOptions(null);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-array (undefined)', () => {
|
||||
const result = normalizeDropdownOptions(undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDropdownOptions — rejection: mixed arrays', () => {
|
||||
test('rejects mixed array (string then object)', () => {
|
||||
const result = normalizeDropdownOptions(['host1', { label: 'Host 2', value: 'host2' }]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects mixed array (object then string)', () => {
|
||||
const result = normalizeDropdownOptions([{ label: 'H', value: 'h' }, 'host2']);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects array of numbers', () => {
|
||||
const result = normalizeDropdownOptions([1, 2, 3]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects array of booleans', () => {
|
||||
const result = normalizeDropdownOptions([true, false]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects array containing null', () => {
|
||||
const result = normalizeDropdownOptions(['host1', null]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects array containing null as first element', () => {
|
||||
const result = normalizeDropdownOptions([null, 'host1']);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects array of nested arrays', () => {
|
||||
const result = normalizeDropdownOptions([['host1', 'host2']]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDropdownOptions — rejection: objects with bad fields', () => {
|
||||
test('rejects object with missing label', () => {
|
||||
const result = normalizeDropdownOptions([{ value: 'host1' }]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.reason).toMatch(/label/);
|
||||
});
|
||||
|
||||
test('rejects object with missing value', () => {
|
||||
const result = normalizeDropdownOptions([{ label: 'Host 1' }]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.reason).toMatch(/value/);
|
||||
});
|
||||
|
||||
test('rejects object with numeric label', () => {
|
||||
const result = normalizeDropdownOptions([{ label: 1, value: 'host1' }]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects object with numeric value', () => {
|
||||
const result = normalizeDropdownOptions([{ label: 'Host 1', value: 1 }]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects object with boolean label', () => {
|
||||
const result = normalizeDropdownOptions([{ label: true, value: 'host1' }]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects object with null label', () => {
|
||||
const result = normalizeDropdownOptions([{ label: null, value: 'host1' }]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('does not partially apply valid elements from invalid array', () => {
|
||||
// First element is valid, second is not — entire mapping rejected
|
||||
const result = normalizeDropdownOptions([
|
||||
{ label: 'Good', value: 'good' },
|
||||
{ label: 'Bad', value: 42 },
|
||||
]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Warnings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeDropdownOptions — warnings', () => {
|
||||
test('duplicate values produce a warning but options are applied', () => {
|
||||
const result = normalizeDropdownOptions([
|
||||
{ label: 'A', value: 'x' },
|
||||
{ label: 'B', value: 'x' },
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options).toHaveLength(2);
|
||||
expect(result.warnings.some((w) => w.includes('"x"'))).toBe(true);
|
||||
});
|
||||
|
||||
test('empty label produces a warning but options are applied', () => {
|
||||
const result = normalizeDropdownOptions([{ label: '', value: 'host1' }]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options[0]).toEqual({ label: '', value: 'host1' });
|
||||
expect(result.warnings.some((w) => w.toLowerCase().includes('empty label'))).toBe(true);
|
||||
});
|
||||
|
||||
test('empty value produces a warning but options are applied', () => {
|
||||
const result = normalizeDropdownOptions([{ label: 'Host 1', value: '' }]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options[0]).toEqual({ label: 'Host 1', value: '' });
|
||||
expect(result.warnings.some((w) => w.toLowerCase().includes('empty value'))).toBe(true);
|
||||
});
|
||||
|
||||
test('empty string in Shape A produces a warning but option is applied', () => {
|
||||
const result = normalizeDropdownOptions(['', 'host1']);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.options[0]).toEqual({ label: '', value: '' });
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Selection reconciliation ──────────────────────────────────────────────────
|
||||
|
||||
describe('reconcileDropdownSelection', () => {
|
||||
const options: DropdownOption[] = [
|
||||
{ label: 'Host 1', value: 'host1' },
|
||||
{ label: 'Host 2', value: 'host2' },
|
||||
{ label: 'Host 3', value: 'host3' },
|
||||
];
|
||||
|
||||
test('runtime value exists in new options → preserved', () => {
|
||||
expect(reconcileDropdownSelection('host2', '', options)).toBe('host2');
|
||||
});
|
||||
|
||||
test('configured value exists in new options → preserved (no runtime value)', () => {
|
||||
expect(reconcileDropdownSelection(undefined, 'host2', options)).toBe('host2');
|
||||
});
|
||||
|
||||
test('runtime value not in new options → clears to ""', () => {
|
||||
expect(reconcileDropdownSelection('missing', '', options)).toBe('');
|
||||
});
|
||||
|
||||
test('configured value not in new options → clears to ""', () => {
|
||||
expect(reconcileDropdownSelection(undefined, 'missing', options)).toBe('');
|
||||
});
|
||||
|
||||
test('no runtime value, no configured value → ""', () => {
|
||||
expect(reconcileDropdownSelection(undefined, '', options)).toBe('');
|
||||
});
|
||||
|
||||
test('empty options array always clears selection', () => {
|
||||
expect(reconcileDropdownSelection('host1', 'host1', [])).toBe('');
|
||||
});
|
||||
|
||||
test('empty options with no prior selection stays ""', () => {
|
||||
expect(reconcileDropdownSelection(undefined, '', [])).toBe('');
|
||||
});
|
||||
|
||||
test('first option is NEVER auto-selected', () => {
|
||||
const result = reconcileDropdownSelection(undefined, '', options);
|
||||
expect(result).toBe('');
|
||||
expect(result).not.toBe('host1');
|
||||
});
|
||||
|
||||
test('runtime "" (empty string) is treated as no selection', () => {
|
||||
// "" runtime value → effective is "" → returns ""
|
||||
expect(reconcileDropdownSelection('', '', options)).toBe('');
|
||||
});
|
||||
|
||||
test('runtime value takes priority over configured value', () => {
|
||||
// runtime = host1, configured = host2, both in options
|
||||
// runtime wins
|
||||
expect(reconcileDropdownSelection('host1', 'host2', options)).toBe('host1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Target capability regression ──────────────────────────────────────────────
|
||||
|
||||
describe('Step 17.1 regression: .value targets still work', () => {
|
||||
test('JsonViewer.value accepted', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label.value accepted', () => {
|
||||
expect(isTargetPropertySupported('Label', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Dropdown is NOT added as a .value target', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'value')).toBe(false);
|
||||
expect(SUPPORTED_TARGET_TYPES.has('Dropdown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step 17.2 regression: Dropdown static options shape still valid', () => {
|
||||
test('DropdownOption type shape', () => {
|
||||
const opt: DropdownOption = { label: 'Dev', value: 'dev' };
|
||||
expect(opt.label).toBe('Dev');
|
||||
expect(opt.value).toBe('dev');
|
||||
});
|
||||
|
||||
test('normalizeDropdownOptions produces clean DropdownOption objects', () => {
|
||||
const result = normalizeDropdownOptions([{ label: 'Dev', value: 'dev' }]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const opt = result.options[0];
|
||||
expect(typeof opt.label).toBe('string');
|
||||
expect(typeof opt.value).toBe('string');
|
||||
});
|
||||
});
|
||||
431
frontend/src/components/Preview/table-response-mapping.test.ts
Normal file
431
frontend/src/components/Preview/table-response-mapping.test.ts
Normal file
@ -0,0 +1,431 @@
|
||||
/**
|
||||
* table-response-mapping.test.ts — Step 17.5
|
||||
*
|
||||
* Tests covering:
|
||||
* - normalizeTableRows: valid, empty, invalid shapes
|
||||
* - Runtime behavior: precedence, fallback, selection clearing
|
||||
* - Target capability: Table.rows accepted; other Table targets rejected
|
||||
* - Regression: static Table, Dropdown options, Label/JsonViewer value
|
||||
*/
|
||||
|
||||
import {
|
||||
normalizeTableRows,
|
||||
isTargetPropertySupported,
|
||||
} from './bindingUtils';
|
||||
|
||||
import type { NormalizeTableRowsResult } from './bindingUtils';
|
||||
|
||||
// ── normalizeTableRows: valid shapes ─────────────────────────────────────────
|
||||
|
||||
describe('normalizeTableRows — valid shapes', () => {
|
||||
test('valid array of objects is accepted', () => {
|
||||
const result = normalizeTableRows([
|
||||
{ hostname: 'host-one', environment: 'development', status: 'online' },
|
||||
{ hostname: 'host-two', environment: 'test', status: 'offline' },
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('empty array is accepted and produces empty rows', () => {
|
||||
const result = normalizeTableRows([]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('row order is preserved', () => {
|
||||
const raw = [
|
||||
{ order: 1, name: 'first' },
|
||||
{ order: 2, name: 'second' },
|
||||
{ order: 3, name: 'third' },
|
||||
];
|
||||
const result = normalizeTableRows(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.rows[0].order).toBe(1);
|
||||
expect(result.rows[1].order).toBe(2);
|
||||
expect(result.rows[2].order).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates shallow copies — no retained references to source objects', () => {
|
||||
const originalRow = { hostname: 'h1', status: 'online' };
|
||||
const raw = [originalRow];
|
||||
const result = normalizeTableRows(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
// Shallow copy: different object identity
|
||||
expect(result.rows[0]).not.toBe(originalRow);
|
||||
// Shallow copy: same field values
|
||||
expect(result.rows[0]).toEqual(originalRow);
|
||||
}
|
||||
});
|
||||
|
||||
test('nested object cell value is retained in shallow copy', () => {
|
||||
const nested = { region: 'us-east-1' };
|
||||
const raw = [{ hostname: 'h1', meta: nested }];
|
||||
const result = normalizeTableRows(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
// Shallow copy — the nested object itself is the same reference
|
||||
expect(result.rows[0].meta).toBe(nested);
|
||||
}
|
||||
});
|
||||
|
||||
test('nested array cell value is retained in shallow copy', () => {
|
||||
const tags = ['prod', 'web'];
|
||||
const raw = [{ hostname: 'h1', tags }];
|
||||
const result = normalizeTableRows(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.rows[0].tags).toBe(tags);
|
||||
}
|
||||
});
|
||||
|
||||
test('row with mixed value types is accepted', () => {
|
||||
const raw = [{
|
||||
str: 'text',
|
||||
num: 42,
|
||||
bool: true,
|
||||
nil: null,
|
||||
nested: { key: 'val' },
|
||||
arr: [1, 2],
|
||||
}];
|
||||
const result = normalizeTableRows(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.rows[0].str).toBe('text');
|
||||
expect(result.rows[0].num).toBe(42);
|
||||
expect(result.rows[0].bool).toBe(true);
|
||||
expect(result.rows[0].nil).toBe(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── normalizeTableRows: invalid shapes ────────────────────────────────────────
|
||||
|
||||
describe('normalizeTableRows — invalid shapes', () => {
|
||||
test('non-array object is rejected', () => {
|
||||
const result = normalizeTableRows({ hostname: 'h1' });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('array');
|
||||
});
|
||||
|
||||
test('string is rejected', () => {
|
||||
const result = normalizeTableRows('host-one');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('number is rejected', () => {
|
||||
const result = normalizeTableRows(42);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('null is rejected', () => {
|
||||
const result = normalizeTableRows(null);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('null');
|
||||
});
|
||||
|
||||
test('undefined is rejected', () => {
|
||||
const result = normalizeTableRows(undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('array of strings is rejected', () => {
|
||||
const result = normalizeTableRows(['host-one', 'host-two']);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toContain('index 0');
|
||||
expect(result.reason).toContain('string');
|
||||
}
|
||||
});
|
||||
|
||||
test('array of numbers is rejected', () => {
|
||||
const result = normalizeTableRows([1, 2, 3]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('index 0');
|
||||
});
|
||||
|
||||
test('array of booleans is rejected', () => {
|
||||
const result = normalizeTableRows([true, false]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('array containing null is rejected', () => {
|
||||
const result = normalizeTableRows([null]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toContain('index 0');
|
||||
expect(result.reason).toContain('null');
|
||||
}
|
||||
});
|
||||
|
||||
test('array containing a nested array is rejected', () => {
|
||||
const result = normalizeTableRows([['nested']]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toContain('index 0');
|
||||
expect(result.reason).toContain('array');
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed object/primitive array is rejected at first bad element', () => {
|
||||
const result = normalizeTableRows([
|
||||
{ hostname: 'h1' },
|
||||
'invalid-string',
|
||||
]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('index 1');
|
||||
});
|
||||
|
||||
test('null at non-zero index is rejected with correct index', () => {
|
||||
const result = normalizeTableRows([
|
||||
{ hostname: 'h1' },
|
||||
null,
|
||||
]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('index 1');
|
||||
});
|
||||
|
||||
test('entire array is rejected on invalid element — no partial rows returned', () => {
|
||||
const result: NormalizeTableRowsResult = normalizeTableRows([
|
||||
{ hostname: 'h1' },
|
||||
null,
|
||||
{ hostname: 'h3' },
|
||||
]);
|
||||
expect(result.ok).toBe(false);
|
||||
// ok:false has no rows property — confirm shape
|
||||
expect('rows' in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Runtime behavior ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Table runtime rows precedence', () => {
|
||||
type RuntimeState = {
|
||||
rows?: Array<Record<string, unknown>>;
|
||||
selectedIndex?: number;
|
||||
selectedRow?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const configuredRows = [{ hostname: 'fallback-host', status: 'configured' }];
|
||||
|
||||
function effectiveRows(
|
||||
runtimeState: RuntimeState | undefined,
|
||||
configured: Array<Record<string, unknown>>,
|
||||
): Array<Record<string, unknown>> {
|
||||
// Mirrors the PreviewComponent logic exactly
|
||||
return runtimeState?.rows !== undefined ? runtimeState.rows : configured;
|
||||
}
|
||||
|
||||
test('runtime rows override configured rows when defined', () => {
|
||||
const runtimeState: RuntimeState = {
|
||||
rows: [{ hostname: 'runtime-host', status: 'dynamic' }],
|
||||
};
|
||||
const result = effectiveRows(runtimeState, configuredRows);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hostname).toBe('runtime-host');
|
||||
});
|
||||
|
||||
test('empty runtime rows override configured rows — do not fall back', () => {
|
||||
const runtimeState: RuntimeState = { rows: [] };
|
||||
const result = effectiveRows(runtimeState, configuredRows);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('configured rows are used when runtime rows are undefined', () => {
|
||||
const result = effectiveRows(undefined, configuredRows);
|
||||
expect(result).toBe(configuredRows);
|
||||
});
|
||||
|
||||
test('configured rows are used when runtimeState is undefined', () => {
|
||||
const result = effectiveRows(undefined, configuredRows);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hostname).toBe('fallback-host');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Selection clearing when rows are replaced ─────────────────────────────────
|
||||
|
||||
describe('Table selection clearing on rows replacement', () => {
|
||||
type TableRuntimeState = {
|
||||
rows?: Array<Record<string, unknown>>;
|
||||
selectedIndex?: number;
|
||||
selectedRow?: Record<string, unknown>;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulates the state update performed in applyResponseBindings for a .rows target.
|
||||
* Mirrors the Step 17.5 contract exactly.
|
||||
*/
|
||||
function applyRuntimeRows(
|
||||
existing: TableRuntimeState,
|
||||
newRows: Array<Record<string, unknown>>,
|
||||
): TableRuntimeState {
|
||||
return {
|
||||
...existing,
|
||||
rows: newRows,
|
||||
selectedIndex: undefined,
|
||||
selectedRow: undefined,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
test('replacing rows clears selectedIndex', () => {
|
||||
const existing: TableRuntimeState = { selectedIndex: 1, selectedRow: { hostname: 'h2' } };
|
||||
const updated = applyRuntimeRows(existing, [{ hostname: 'new-host' }]);
|
||||
expect(updated.selectedIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
test('replacing rows clears selectedRow', () => {
|
||||
const existing: TableRuntimeState = { selectedIndex: 0, selectedRow: { hostname: 'h1' } };
|
||||
const updated = applyRuntimeRows(existing, [{ hostname: 'new-host' }]);
|
||||
expect(updated.selectedRow).toBeUndefined();
|
||||
});
|
||||
|
||||
test('first row is not auto-selected after replacement', () => {
|
||||
const existing: TableRuntimeState = {};
|
||||
const updated = applyRuntimeRows(existing, [
|
||||
{ hostname: 'host-1' },
|
||||
{ hostname: 'host-2' },
|
||||
]);
|
||||
expect(updated.selectedIndex).toBeUndefined();
|
||||
expect(updated.selectedRow).toBeUndefined();
|
||||
});
|
||||
|
||||
test('replacing rows with empty array clears selection', () => {
|
||||
const existing: TableRuntimeState = { selectedIndex: 2, selectedRow: { hostname: 'h3' } };
|
||||
const updated = applyRuntimeRows(existing, []);
|
||||
expect(updated.rows).toHaveLength(0);
|
||||
expect(updated.selectedIndex).toBeUndefined();
|
||||
expect(updated.selectedRow).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unrelated runtime state is preserved when rows are replaced', () => {
|
||||
const existing: TableRuntimeState = {
|
||||
selectedIndex: 0,
|
||||
selectedRow: { hostname: 'old' },
|
||||
loading: true, // will be cleared to false
|
||||
error: 'old error', // will be cleared
|
||||
};
|
||||
const updated = applyRuntimeRows(existing, [{ hostname: 'new' }]);
|
||||
expect(updated.rows).toHaveLength(1);
|
||||
expect(updated.loading).toBe(false);
|
||||
expect(updated.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('invalid mapping does not modify existing runtime rows', () => {
|
||||
// Simulate: normalizeTableRows fails → skip → existing state unchanged
|
||||
const existingRows = [{ hostname: 'preserved' }];
|
||||
const existing: TableRuntimeState = { rows: existingRows };
|
||||
|
||||
const normalizeResult = normalizeTableRows(['invalid-string']);
|
||||
expect(normalizeResult.ok).toBe(false);
|
||||
|
||||
// Because normalization failed, we do NOT call applyRuntimeRows
|
||||
// existing state is unchanged
|
||||
expect(existing.rows).toBe(existingRows);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Target capability: full model ─────────────────────────────────────────────
|
||||
|
||||
describe('Target capability — Step 17.5 model', () => {
|
||||
// Accepted targets
|
||||
test('JsonViewer.value is accepted', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label.value is accepted', () => {
|
||||
expect(isTargetPropertySupported('Label', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Dropdown.options is accepted', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'options')).toBe(true);
|
||||
});
|
||||
|
||||
test('Table.rows is accepted', () => {
|
||||
expect(isTargetPropertySupported('Table', 'rows')).toBe(true);
|
||||
});
|
||||
|
||||
// Rejected Table targets
|
||||
test('Table.value is rejected', () => {
|
||||
expect(isTargetPropertySupported('Table', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('Table.options is rejected', () => {
|
||||
expect(isTargetPropertySupported('Table', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('Table.selectedRow is rejected', () => {
|
||||
expect(isTargetPropertySupported('Table', 'selectedRow')).toBe(false);
|
||||
});
|
||||
|
||||
test('Table.selectedIndex is rejected', () => {
|
||||
expect(isTargetPropertySupported('Table', 'selectedIndex')).toBe(false);
|
||||
});
|
||||
|
||||
// Rejected rows targets on other components
|
||||
test('JsonViewer.rows is rejected', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'rows')).toBe(false);
|
||||
});
|
||||
|
||||
test('Label.rows is rejected', () => {
|
||||
expect(isTargetPropertySupported('Label', 'rows')).toBe(false);
|
||||
});
|
||||
|
||||
test('Dropdown.rows is rejected', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'rows')).toBe(false);
|
||||
});
|
||||
|
||||
test('Button.rows is rejected', () => {
|
||||
expect(isTargetPropertySupported('Button', 'rows')).toBe(false);
|
||||
});
|
||||
|
||||
test('TextInput.rows is rejected', () => {
|
||||
expect(isTargetPropertySupported('TextInput', 'rows')).toBe(false);
|
||||
});
|
||||
|
||||
// Regression: existing accepted targets still work
|
||||
test('Dropdown.value is rejected (not a response-binding target)', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('Label.options is rejected', () => {
|
||||
expect(isTargetPropertySupported('Label', 'options')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression: static Table and Dropdown still work ─────────────────────────
|
||||
|
||||
describe('Regression: static rendering and Dropdown', () => {
|
||||
test('normalizeTableRows accepts three-row inventory', () => {
|
||||
const result = normalizeTableRows([
|
||||
{ hostname: 'host-one', environment: 'development', status: 'online' },
|
||||
{ hostname: 'host-two', environment: 'test', status: 'offline' },
|
||||
{ hostname: 'host-three', environment: 'production', status: 'online' },
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.rows).toHaveLength(3);
|
||||
expect(result.rows[0].hostname).toBe('host-one');
|
||||
expect(result.rows[2].status).toBe('online');
|
||||
}
|
||||
});
|
||||
|
||||
test('Dropdown.options still accepted (regression)', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'options')).toBe(true);
|
||||
});
|
||||
|
||||
test('JsonViewer.value still accepted (regression)', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label.value still accepted (regression)', () => {
|
||||
expect(isTargetPropertySupported('Label', 'value')).toBe(true);
|
||||
});
|
||||
});
|
||||
627
frontend/src/components/Preview/table.test.ts
Normal file
627
frontend/src/components/Preview/table.test.ts
Normal file
@ -0,0 +1,627 @@
|
||||
/**
|
||||
* table.test.ts — Step 17.4
|
||||
*
|
||||
* Tests covering:
|
||||
* - Columns: explicit, derived, empty, first-row key order, missing values
|
||||
* - Duplicate column-key diagnostics
|
||||
* - Rows: valid, empty, invalid primitives, invalid arrays, null, nested objects/arrays
|
||||
* - Rendering helpers: string, number, boolean, null, undefined, object, array, error
|
||||
* - Runtime selection: select, change, disabled, no mutation of configured rows
|
||||
* - Target capability regression: Table.rows/value rejected, existing targets accepted
|
||||
*/
|
||||
|
||||
import { isTargetPropertySupported } from './bindingUtils';
|
||||
|
||||
// ── renderCellValue — extracted as a pure function for testing ────────────────
|
||||
// Mirrors the implementation in CanvasComponent.tsx and PreviewComponent.tsx.
|
||||
|
||||
function renderCellValue(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '[serialisation error]';
|
||||
}
|
||||
}
|
||||
|
||||
// ── deriveColumns — mirrors the logic in both renderers ──────────────────────
|
||||
// Canonical shape: key + header + optional width (matches schema and TypeScript types)
|
||||
|
||||
type TableColumn = { key: string; header: string; width?: number };
|
||||
type TableRow = Record<string, unknown>;
|
||||
|
||||
function deriveEffectiveCols(columns: TableColumn[], rows: TableRow[]): TableColumn[] {
|
||||
if (columns.length > 0) return columns;
|
||||
if (rows.length > 0) return Object.keys(rows[0]).map((k) => ({ key: k, header: k }));
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── validateTableRows — mirrors TableRowsEditor validation ───────────────────
|
||||
|
||||
type RowValidationResult =
|
||||
| { ok: true; rows: TableRow[] }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function validateTableRows(input: unknown): RowValidationResult {
|
||||
if (!Array.isArray(input)) {
|
||||
return { ok: false, reason: 'Rows must be a JSON array.' };
|
||||
}
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const el = input[i];
|
||||
if (el === null || typeof el !== 'object' || Array.isArray(el)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Row at index ${i} must be a non-null object (got ${
|
||||
el === null ? 'null' : Array.isArray(el) ? 'array' : typeof el
|
||||
}).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, rows: input as TableRow[] };
|
||||
}
|
||||
|
||||
// ── validateTableColumns — mirrors TableColumnsEditor validation ──────────────
|
||||
|
||||
function validateTableColumns(columns: TableColumn[]): string | null {
|
||||
const keys = columns.map((c) => c.key).filter(Boolean);
|
||||
const dupKeys = keys.filter((k, i) => keys.indexOf(k) !== i);
|
||||
if (dupKeys.length > 0) {
|
||||
return `Duplicate column keys: ${dupKeys.map((k) => `"${k}"`).join(', ')}.`;
|
||||
}
|
||||
if (columns.some((c) => !c.key)) return 'Column key must not be empty.';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Columns: explicit ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Table columns — explicit', () => {
|
||||
test('explicit columns are used as-is when provided', () => {
|
||||
const columns: TableColumn[] = [
|
||||
{ key: 'hostname', header: 'Hostname' },
|
||||
{ key: 'status', header: 'Status' },
|
||||
];
|
||||
const rows: TableRow[] = [{ hostname: 'host-1', status: 'online' }];
|
||||
const effective = deriveEffectiveCols(columns, rows);
|
||||
expect(effective).toHaveLength(2);
|
||||
expect(effective[0]).toEqual({ key: 'hostname', header: 'Hostname' });
|
||||
expect(effective[1]).toEqual({ key: 'status', header: 'Status' });
|
||||
});
|
||||
|
||||
test('explicit columns override first-row keys even when keys differ', () => {
|
||||
const columns: TableColumn[] = [{ key: 'a', header: 'Alpha' }];
|
||||
const rows: TableRow[] = [{ a: 1, b: 2 }];
|
||||
const effective = deriveEffectiveCols(columns, rows);
|
||||
expect(effective).toHaveLength(1);
|
||||
expect(effective[0].key).toBe('a');
|
||||
});
|
||||
|
||||
test('explicit columns with optional width are preserved as-is', () => {
|
||||
const columns: TableColumn[] = [
|
||||
{ key: 'hostname', header: 'Hostname', width: 180 },
|
||||
{ key: 'status', header: 'Status' },
|
||||
];
|
||||
const effective = deriveEffectiveCols(columns, []);
|
||||
expect(effective[0]).toEqual({ key: 'hostname', header: 'Hostname', width: 180 });
|
||||
expect(effective[1]).toEqual({ key: 'status', header: 'Status' });
|
||||
});
|
||||
|
||||
test('width is optional — column without width is valid', () => {
|
||||
const columns: TableColumn[] = [
|
||||
{ key: 'id', header: 'ID' },
|
||||
];
|
||||
expect(columns[0].width).toBeUndefined();
|
||||
const effective = deriveEffectiveCols(columns, []);
|
||||
expect(effective[0].key).toBe('id');
|
||||
expect(effective[0].header).toBe('ID');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Columns: derived ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Table columns — derived', () => {
|
||||
test('derives columns from first-row keys when columns is empty', () => {
|
||||
const columns: TableColumn[] = [];
|
||||
const rows: TableRow[] = [{ hostname: 'h1', environment: 'dev', status: 'up' }];
|
||||
const effective = deriveEffectiveCols(columns, rows);
|
||||
expect(effective).toHaveLength(3);
|
||||
expect(effective[0]).toEqual({ key: 'hostname', header: 'hostname' });
|
||||
expect(effective[1]).toEqual({ key: 'environment', header: 'environment' });
|
||||
expect(effective[2]).toEqual({ key: 'status', header: 'status' });
|
||||
});
|
||||
|
||||
test('preserves first-row key order in derived columns', () => {
|
||||
const rows: TableRow[] = [{ z: 1, a: 2, m: 3 }];
|
||||
const effective = deriveEffectiveCols([], rows);
|
||||
expect(effective.map((c) => c.key)).toEqual(['z', 'a', 'm']);
|
||||
});
|
||||
|
||||
test('uses key as header for derived columns', () => {
|
||||
const rows: TableRow[] = [{ myField: 'val' }];
|
||||
const effective = deriveEffectiveCols([], rows);
|
||||
expect(effective[0].key).toBe('myField');
|
||||
expect(effective[0].header).toBe('myField');
|
||||
});
|
||||
|
||||
test('does not union keys from other rows — uses first row only', () => {
|
||||
const rows: TableRow[] = [
|
||||
{ a: 1 },
|
||||
{ a: 2, b: 3 }, // extra key b in second row — should NOT appear
|
||||
];
|
||||
const effective = deriveEffectiveCols([], rows);
|
||||
expect(effective).toHaveLength(1);
|
||||
expect(effective[0].key).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Columns: empty ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Table columns — empty', () => {
|
||||
test('empty columns and empty rows produces no columns', () => {
|
||||
const effective = deriveEffectiveCols([], []);
|
||||
expect(effective).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('empty columns with empty rows does not crash', () => {
|
||||
expect(() => deriveEffectiveCols([], [])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Columns: missing values in later rows ─────────────────────────────────────
|
||||
|
||||
describe('Table columns — missing values', () => {
|
||||
test('later rows missing a column value produce blank cell', () => {
|
||||
const cols: TableColumn[] = [
|
||||
{ key: 'a', header: 'A' },
|
||||
{ key: 'b', header: 'B' },
|
||||
];
|
||||
const rows: TableRow[] = [
|
||||
{ a: 'row1a', b: 'row1b' },
|
||||
{ a: 'row2a' }, // missing b
|
||||
];
|
||||
// deriveEffectiveCols uses explicit cols
|
||||
const effective = deriveEffectiveCols(cols, rows);
|
||||
expect(effective).toHaveLength(2);
|
||||
// Cell value for missing field
|
||||
expect(renderCellValue(rows[1]['b'])).toBe(''); // undefined → ''
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate column-key diagnostics ─────────────────────────────────────────
|
||||
|
||||
describe('Table column-key validation', () => {
|
||||
test('no error for unique keys', () => {
|
||||
const cols: TableColumn[] = [
|
||||
{ key: 'a', label: 'A' },
|
||||
{ key: 'b', label: 'B' },
|
||||
];
|
||||
expect(validateTableColumns(cols)).toBeNull();
|
||||
});
|
||||
|
||||
test('warns about duplicate keys', () => {
|
||||
const cols: TableColumn[] = [
|
||||
{ key: 'a', label: 'A' },
|
||||
{ key: 'a', label: 'A2' },
|
||||
];
|
||||
const result = validateTableColumns(cols);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('Duplicate column keys');
|
||||
expect(result).toContain('"a"');
|
||||
});
|
||||
|
||||
test('warns about empty key', () => {
|
||||
const cols: TableColumn[] = [
|
||||
{ key: '', label: 'Missing key' },
|
||||
];
|
||||
const result = validateTableColumns(cols);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('empty');
|
||||
});
|
||||
|
||||
test('no duplicate warning when key appears only once', () => {
|
||||
const cols: TableColumn[] = [
|
||||
{ key: 'x', label: 'X' },
|
||||
{ key: 'y', label: 'Y' },
|
||||
{ key: 'z', label: 'Z' },
|
||||
];
|
||||
expect(validateTableColumns(cols)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rows: validation ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Table rows — validation', () => {
|
||||
test('valid object rows pass', () => {
|
||||
const result = validateTableRows([
|
||||
{ hostname: 'h1', status: 'up' },
|
||||
{ hostname: 'h2', status: 'down' },
|
||||
]);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('empty row array is valid', () => {
|
||||
const result = validateTableRows([]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('non-array is rejected', () => {
|
||||
const result = validateTableRows({ hostname: 'h1' });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('JSON array');
|
||||
});
|
||||
|
||||
test('primitive row is rejected', () => {
|
||||
const result = validateTableRows([42]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('index 0');
|
||||
});
|
||||
|
||||
test('string row is rejected', () => {
|
||||
const result = validateTableRows(['host-1']);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('boolean row is rejected', () => {
|
||||
const result = validateTableRows([true]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('array row is rejected', () => {
|
||||
const result = validateTableRows([['nested']]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('array');
|
||||
});
|
||||
|
||||
test('null row is rejected', () => {
|
||||
const result = validateTableRows([null]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('null');
|
||||
});
|
||||
|
||||
test('invalid row at non-zero index reports correct index', () => {
|
||||
const result = validateTableRows([
|
||||
{ a: 1 },
|
||||
null,
|
||||
]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toContain('index 1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rendering helpers: renderCellValue ───────────────────────────────────────
|
||||
|
||||
describe('renderCellValue', () => {
|
||||
test('string is returned directly', () => {
|
||||
expect(renderCellValue('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
test('empty string is returned as empty string', () => {
|
||||
expect(renderCellValue('')).toBe('');
|
||||
});
|
||||
|
||||
test('number is converted to string', () => {
|
||||
expect(renderCellValue(42)).toBe('42');
|
||||
expect(renderCellValue(3.14)).toBe('3.14');
|
||||
expect(renderCellValue(0)).toBe('0');
|
||||
});
|
||||
|
||||
test('boolean true is converted to string', () => {
|
||||
expect(renderCellValue(true)).toBe('true');
|
||||
});
|
||||
|
||||
test('boolean false is converted to string', () => {
|
||||
expect(renderCellValue(false)).toBe('false');
|
||||
});
|
||||
|
||||
test('null is rendered as "null"', () => {
|
||||
expect(renderCellValue(null)).toBe('null');
|
||||
});
|
||||
|
||||
test('undefined is rendered as empty string', () => {
|
||||
expect(renderCellValue(undefined)).toBe('');
|
||||
});
|
||||
|
||||
test('object is compact JSON stringified', () => {
|
||||
const result = renderCellValue({ key: 'val' });
|
||||
expect(result).toBe('{"key":"val"}');
|
||||
});
|
||||
|
||||
test('array is compact JSON stringified', () => {
|
||||
const result = renderCellValue([1, 2, 3]);
|
||||
expect(result).toBe('[1,2,3]');
|
||||
});
|
||||
|
||||
test('nested object does not produce [object Object]', () => {
|
||||
const result = renderCellValue({ nested: { a: 1 } });
|
||||
expect(result).not.toContain('[object Object]');
|
||||
expect(result).toContain('"nested"');
|
||||
});
|
||||
|
||||
test('nested array inside object is serialised', () => {
|
||||
const result = renderCellValue({ items: ['a', 'b'] });
|
||||
expect(result).toContain('"items"');
|
||||
expect(result).toContain('"a"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Selection: runtime state does not mutate configured rows ─────────────────
|
||||
|
||||
describe('Table row selection — runtime state', () => {
|
||||
const configuredRows: TableRow[] = [
|
||||
{ hostname: 'h1', status: 'online' },
|
||||
{ hostname: 'h2', status: 'offline' },
|
||||
];
|
||||
|
||||
// Simulate componentState update (mirrors usePreviewRuntime.handleTableRowSelect)
|
||||
function selectRow(
|
||||
prevState: Record<string, { selectedIndex?: number; selectedRow?: Record<string, unknown> }>,
|
||||
tableId: string,
|
||||
index: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
return {
|
||||
...prevState,
|
||||
[tableId]: { ...prevState[tableId], selectedIndex: index, selectedRow: row },
|
||||
};
|
||||
}
|
||||
|
||||
test('clicking a row sets selectedIndex and selectedRow', () => {
|
||||
const state = selectRow({}, 'tbl1', 0, configuredRows[0]);
|
||||
expect(state['tbl1'].selectedIndex).toBe(0);
|
||||
expect(state['tbl1'].selectedRow).toEqual({ hostname: 'h1', status: 'online' });
|
||||
});
|
||||
|
||||
test('clicking another row changes the selection', () => {
|
||||
let state = selectRow({}, 'tbl1', 0, configuredRows[0]);
|
||||
state = selectRow(state, 'tbl1', 1, configuredRows[1]);
|
||||
expect(state['tbl1'].selectedIndex).toBe(1);
|
||||
expect(state['tbl1'].selectedRow).toEqual({ hostname: 'h2', status: 'offline' });
|
||||
});
|
||||
|
||||
test('selecting a row does not modify configuredRows', () => {
|
||||
const original = JSON.stringify(configuredRows);
|
||||
selectRow({}, 'tbl1', 0, configuredRows[0]);
|
||||
expect(JSON.stringify(configuredRows)).toBe(original);
|
||||
});
|
||||
|
||||
test('selectedRow is the same object reference as the row (not a copy)', () => {
|
||||
const row = configuredRows[1];
|
||||
const state = selectRow({}, 'tbl1', 1, row);
|
||||
expect(state['tbl1'].selectedRow).toBe(row);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Target capability regression ─────────────────────────────────────────────
|
||||
|
||||
describe('Target capability regression', () => {
|
||||
test('JsonViewer.value is accepted', () => {
|
||||
expect(isTargetPropertySupported('JsonViewer', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Label.value is accepted', () => {
|
||||
expect(isTargetPropertySupported('Label', 'value')).toBe(true);
|
||||
});
|
||||
|
||||
test('Dropdown.options is accepted', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'options')).toBe(true);
|
||||
});
|
||||
|
||||
test('Table.rows is accepted (Step 17.5)', () => {
|
||||
expect(isTargetPropertySupported('Table', 'rows')).toBe(true);
|
||||
});
|
||||
|
||||
test('Table.value is rejected', () => {
|
||||
expect(isTargetPropertySupported('Table', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('Table.selectedRow is rejected', () => {
|
||||
expect(isTargetPropertySupported('Table', 'selectedRow')).toBe(false);
|
||||
});
|
||||
|
||||
test('Table.selectedIndex is rejected', () => {
|
||||
expect(isTargetPropertySupported('Table', 'selectedIndex')).toBe(false);
|
||||
});
|
||||
|
||||
test('Dropdown.value is rejected (not a response-binding target)', () => {
|
||||
expect(isTargetPropertySupported('Dropdown', 'value')).toBe(false);
|
||||
});
|
||||
|
||||
test('Label.options is rejected', () => {
|
||||
expect(isTargetPropertySupported('Label', 'options')).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown component type is rejected', () => {
|
||||
expect(isTargetPropertySupported('UnknownType', 'value')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── TableRowsEditor: local validation contract ────────────────────────────────
|
||||
//
|
||||
// These tests exercise the same logic as TableRowsEditor.handleApply directly.
|
||||
// They confirm:
|
||||
// 1. Valid object rows are accepted and passed to onRowsChange.
|
||||
// 2. The rows array is never sent to /api/projects/validate — only local
|
||||
// validation runs before calling onRowsChange.
|
||||
// 3. Non-array input is rejected locally.
|
||||
// 4. Arrays containing null are rejected.
|
||||
// 5. Arrays containing primitives are rejected.
|
||||
// 6. Arrays containing nested arrays are rejected.
|
||||
// 7. Existing valid rows are preserved when input is invalid (no call to
|
||||
// onRowsChange).
|
||||
|
||||
/**
|
||||
* Simulates the exact handleApply logic from TableRowsEditor so tests can
|
||||
* verify the validation contract without mounting the component.
|
||||
*
|
||||
* Returns { accepted: true, rows } when validation passes and onRowsChange
|
||||
* would be called, or { accepted: false, reason } when rejected locally.
|
||||
*/
|
||||
function simulateRowsEditorApply(draft: string): (
|
||||
| { accepted: true; rows: TableRow[] }
|
||||
| { accepted: false; reason: string }
|
||||
) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(draft);
|
||||
} catch (e: unknown) {
|
||||
return { accepted: false, reason: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { accepted: false, reason: 'Rows must be a JSON array.' };
|
||||
}
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const el = parsed[i];
|
||||
if (el === null || typeof el !== 'object' || Array.isArray(el)) {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: `Row at index ${i} must be a non-null object (got ${
|
||||
el === null ? 'null' : Array.isArray(el) ? 'array' : typeof el
|
||||
}).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { accepted: true, rows: parsed as TableRow[] };
|
||||
}
|
||||
|
||||
describe('TableRowsEditor — local validation (no API call)', () => {
|
||||
test('valid object-array rows are accepted', () => {
|
||||
const result = simulateRowsEditorApply(JSON.stringify([
|
||||
{ hostname: 'host-one', environment: 'development', status: 'online' },
|
||||
{ hostname: 'host-two', environment: 'test', status: 'offline' },
|
||||
]));
|
||||
expect(result.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test('accepted rows are passed to onRowsChange (not to validate API)', () => {
|
||||
// Confirm no fetch/API side-effect is possible: simulateRowsEditorApply
|
||||
// has no network calls — it only returns { accepted, rows }.
|
||||
const result = simulateRowsEditorApply(JSON.stringify([
|
||||
{ hostname: 'host-one', environment: 'development', status: 'online' },
|
||||
]));
|
||||
expect(result.accepted).toBe(true);
|
||||
if (result.accepted) {
|
||||
// The caller (component) receives the rows directly — no API hop required.
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0]).toEqual({
|
||||
hostname: 'host-one', environment: 'development', status: 'online',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rows array is not sent to /api/projects/validate — validation is local', () => {
|
||||
// This test verifies the architectural contract: simulateRowsEditorApply
|
||||
// contains no fetch() calls. The function source is inspected as a string
|
||||
// to prove no network call exists in the validation path.
|
||||
const src = simulateRowsEditorApply.toString();
|
||||
expect(src).not.toContain('fetch');
|
||||
expect(src).not.toContain('validate');
|
||||
expect(src).not.toContain('projects');
|
||||
});
|
||||
|
||||
test('empty array is accepted', () => {
|
||||
const result = simulateRowsEditorApply('[]');
|
||||
expect(result.accepted).toBe(true);
|
||||
if (result.accepted) expect(result.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('non-array JSON object is rejected locally', () => {
|
||||
const result = simulateRowsEditorApply('{"hostname":"h1"}');
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) expect(result.reason).toContain('JSON array');
|
||||
});
|
||||
|
||||
test('non-array JSON string is rejected locally', () => {
|
||||
const result = simulateRowsEditorApply('"just a string"');
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) expect(result.reason).toContain('JSON array');
|
||||
});
|
||||
|
||||
test('malformed JSON is rejected locally with parse error', () => {
|
||||
const result = simulateRowsEditorApply('[{bad json}]');
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) expect(result.reason).toContain('Invalid JSON');
|
||||
});
|
||||
|
||||
test('array containing null is rejected locally', () => {
|
||||
const result = simulateRowsEditorApply('[null]');
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) {
|
||||
expect(result.reason).toContain('index 0');
|
||||
expect(result.reason).toContain('null');
|
||||
}
|
||||
});
|
||||
|
||||
test('array containing a primitive number is rejected locally', () => {
|
||||
const result = simulateRowsEditorApply('[42]');
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) expect(result.reason).toContain('index 0');
|
||||
});
|
||||
|
||||
test('array containing a primitive string is rejected locally', () => {
|
||||
const result = simulateRowsEditorApply('["host-one"]');
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) expect(result.reason).toContain('index 0');
|
||||
});
|
||||
|
||||
test('array containing a nested array is rejected locally', () => {
|
||||
const result = simulateRowsEditorApply('[["nested"]]');
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) {
|
||||
expect(result.reason).toContain('index 0');
|
||||
expect(result.reason).toContain('array');
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid row at non-zero index is reported with correct index', () => {
|
||||
const result = simulateRowsEditorApply(JSON.stringify([
|
||||
{ hostname: 'h1' },
|
||||
null,
|
||||
]));
|
||||
expect(result.accepted).toBe(false);
|
||||
if (!result.accepted) expect(result.reason).toContain('index 1');
|
||||
});
|
||||
|
||||
test('existing rows are preserved when input is invalid — onRowsChange is not called', () => {
|
||||
// Simulate the component: only call onRowsChange when accepted === true.
|
||||
const existingRows: TableRow[] = [{ hostname: 'preserved' }];
|
||||
let currentRows = existingRows;
|
||||
|
||||
const applyRows = (draft: string) => {
|
||||
const result = simulateRowsEditorApply(draft);
|
||||
if (result.accepted) {
|
||||
currentRows = result.rows; // mirrors onRowsChange
|
||||
}
|
||||
// If not accepted, currentRows is untouched
|
||||
};
|
||||
|
||||
applyRows('[bad json]'); // invalid → currentRows unchanged
|
||||
expect(currentRows).toBe(existingRows);
|
||||
|
||||
applyRows('"not an array"'); // not array → unchanged
|
||||
expect(currentRows).toBe(existingRows);
|
||||
|
||||
applyRows('[null]'); // null row → unchanged
|
||||
expect(currentRows).toBe(existingRows);
|
||||
});
|
||||
|
||||
test('three-row inventory example from manual retest is accepted', () => {
|
||||
const draft = JSON.stringify([
|
||||
{ hostname: 'host-one', environment: 'development', status: 'online' },
|
||||
{ hostname: 'host-two', environment: 'test', status: 'offline' },
|
||||
{ hostname: 'host-three', environment: 'production', status: 'online' },
|
||||
]);
|
||||
const result = simulateRowsEditorApply(draft);
|
||||
expect(result.accepted).toBe(true);
|
||||
if (result.accepted) {
|
||||
expect(result.rows).toHaveLength(3);
|
||||
expect(result.rows[2].hostname).toBe('host-three');
|
||||
}
|
||||
});
|
||||
});
|
||||
416
frontend/src/components/Preview/templateUtils.ts
Normal file
416
frontend/src/components/Preview/templateUtils.ts
Normal file
@ -0,0 +1,416 @@
|
||||
/**
|
||||
* templateUtils — Step 16 / 16.5 / 18.1
|
||||
*
|
||||
* Pure utilities for template interpolation and template diagnostics.
|
||||
* Shared between the Preview runtime (usePreviewRuntime) and the
|
||||
* Actions & Bindings inspector (ActionInspector).
|
||||
*
|
||||
* Supported namespaces:
|
||||
* {{components.<name>.value}} — component runtime value (Steps 16–17)
|
||||
* {{variables.<name>}} — global runtime variable (Step 18.1)
|
||||
*
|
||||
* Interpolation rules — components:
|
||||
* Component found + has textValue → use textValue
|
||||
* Component found, no textValue → use "" (empty string)
|
||||
* Component not found → throw Error (caller surfaces it)
|
||||
*
|
||||
* Interpolation rules — variables:
|
||||
* string → original string
|
||||
* number/boolean → String(value)
|
||||
* object/array → JSON.stringify(value)
|
||||
* null → "null"
|
||||
* undefined → "" (variable declared but not yet set)
|
||||
* undeclared → throw Error (configuration error — caller surfaces it)
|
||||
*
|
||||
* No project JSON is ever mutated here. All functions return new objects
|
||||
* or plain values.
|
||||
*/
|
||||
|
||||
import type { RestAction } from '../../types/project';
|
||||
|
||||
// ── Template token ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single parsed template token found in an action field.
|
||||
*
|
||||
* For component tokens:
|
||||
* namespace = "components"
|
||||
* name = component name (e.g. "hostnameInput")
|
||||
* property = "value"
|
||||
* variableName = null
|
||||
*
|
||||
* For variable tokens:
|
||||
* namespace = "variables"
|
||||
* name = variable name (e.g. "selectedHostId")
|
||||
* property = null
|
||||
* variableName = variable name (same as name, convenience accessor)
|
||||
*
|
||||
* raw is the full placeholder string, e.g. "{{components.hostnameInput.value}}".
|
||||
*/
|
||||
export type TemplateToken = {
|
||||
/** The full placeholder string */
|
||||
raw: string;
|
||||
/** Where in the action this token was found, e.g. "queryParameters.hostname" */
|
||||
location: string;
|
||||
/** "components" | "variables" */
|
||||
namespace: 'components' | 'variables';
|
||||
/** Component name or variable name */
|
||||
name: string;
|
||||
/** Component property (e.g. "value"); null for variable tokens */
|
||||
propertyName: string | null;
|
||||
/** True if the token matched a supported pattern */
|
||||
wellFormed: boolean;
|
||||
// ── Deprecated aliases kept for backward compatibility ──────────────────────
|
||||
/** @deprecated Use `name` instead */
|
||||
componentName: string | null;
|
||||
};
|
||||
|
||||
// ── Malformed token ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A suspected-but-malformed template token: an opening "{{" was found but
|
||||
* the content did not close with "}}" before end-of-string, OR an expression
|
||||
* beginning with "{{variables." that does not match the valid grammar
|
||||
* {{variables.<singleName>}} (variable names may not contain dots).
|
||||
*/
|
||||
export type MalformedToken = {
|
||||
/** The partial text that starts with "{{" */
|
||||
raw: string;
|
||||
/** Where in the action this fragment was found */
|
||||
location: string;
|
||||
};
|
||||
|
||||
// ── Variable expression classifier ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classifies a raw template string segment as it relates to the variables namespace.
|
||||
*
|
||||
* Returns:
|
||||
* 'valid' — the segment is exactly {{variables.<singleName>}} (no dots in name)
|
||||
* 'malformed' — the segment starts with {{variables. but is not valid grammar
|
||||
* 'none' — the segment does not contain a {{variables. prefix at all
|
||||
*
|
||||
* This is the single source of truth used by both the extractor/runtime
|
||||
* (templateUtils) and the Inspector (ActionInspector) so they cannot disagree.
|
||||
*
|
||||
* Valid grammar: {{variables.<singleName>}}
|
||||
* where <singleName> is one or more characters that are not ".", "}", or "{".
|
||||
*
|
||||
* Malformed examples:
|
||||
* {{variables.customer.id}} — dot in name
|
||||
* {{variables.}} — empty name
|
||||
* {{variables.name} — unclosed (only one closing brace)
|
||||
* {{variables.name.extra}} — dot in name
|
||||
*/
|
||||
export function classifyVariableExpression(
|
||||
segment: string,
|
||||
): 'valid' | 'malformed' | 'none' {
|
||||
if (!segment.includes('{{variables.')) return 'none';
|
||||
if (/\{\{variables\.([^}.]+)\}\}/.test(segment)) return 'valid';
|
||||
return 'malformed';
|
||||
}
|
||||
|
||||
// ── Extraction ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Matches {{components.<name>.<prop>}}
|
||||
const COMPONENT_RE = /\{\{components\.([^}.]+)\.([^}]+)\}\}/g;
|
||||
// Matches {{variables.<name>}} — name must not contain dots or closing braces
|
||||
const VARIABLE_RE = /\{\{variables\.([^}.]+)\}\}/g;
|
||||
|
||||
/**
|
||||
* Extracts all well-formed component and variable tokens from a string.
|
||||
*/
|
||||
function extractFromString(value: string, location: string): TemplateToken[] {
|
||||
const tokens: TemplateToken[] = [];
|
||||
|
||||
COMPONENT_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = COMPONENT_RE.exec(value)) !== null) {
|
||||
tokens.push({
|
||||
raw: m[0],
|
||||
location,
|
||||
namespace: 'components',
|
||||
name: m[1],
|
||||
propertyName: m[2],
|
||||
wellFormed: true,
|
||||
componentName: m[1], // backward-compat alias
|
||||
});
|
||||
}
|
||||
|
||||
VARIABLE_RE.lastIndex = 0;
|
||||
while ((m = VARIABLE_RE.exec(value)) !== null) {
|
||||
tokens.push({
|
||||
raw: m[0],
|
||||
location,
|
||||
namespace: 'variables',
|
||||
name: m[1],
|
||||
propertyName: null,
|
||||
wellFormed: true,
|
||||
componentName: null, // not a component token
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects malformed template syntax in a string value:
|
||||
*
|
||||
* Pass 1 — malformed {{variables.…}} expressions:
|
||||
* Scans for every {{variables. candidate and classifies it. Each malformed
|
||||
* result's opener index is recorded so pass 2 can suppress the duplicate
|
||||
* generic diagnostic for the same defect.
|
||||
*
|
||||
* Pass 2 — genuine unmatched openers:
|
||||
* Scans left-to-right with a stack to find the positions of openers that are
|
||||
* never matched by a subsequent "}}" closer. For each such position that is
|
||||
* NOT already covered by a malformed variable expression from pass 1, a
|
||||
* generic missing-closing-braces entry is emitted from the exact unmatched
|
||||
* opener position.
|
||||
*
|
||||
* Returns exactly one MalformedToken per distinct problem.
|
||||
*/
|
||||
function detectMalformed(value: string, location: string): MalformedToken[] {
|
||||
const results: MalformedToken[] = [];
|
||||
|
||||
// ── Pass 1: malformed {{variables.…}} expressions ─────────────────────────
|
||||
// Record the opener position of each malformed variable expression so pass 2
|
||||
// can avoid emitting a duplicate generic diagnostic for the same opener.
|
||||
const varNsRe = /\{\{variables\.[^}]*\}?\}?/g;
|
||||
const malformedVarOpenerPositions = new Set<number>();
|
||||
let m: RegExpExecArray | null;
|
||||
varNsRe.lastIndex = 0;
|
||||
while ((m = varNsRe.exec(value)) !== null) {
|
||||
const candidate = m[0];
|
||||
if (classifyVariableExpression(candidate) === 'malformed') {
|
||||
results.push({ raw: candidate, location });
|
||||
malformedVarOpenerPositions.add(m.index);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: genuine unmatched openers via left-to-right stack scan ─────────
|
||||
// Walk the string character by character, pushing opener positions onto a
|
||||
// stack on "{{" and popping on "}}". Positions left on the stack at the end
|
||||
// are genuinely unmatched. We emit a generic diagnostic only for positions
|
||||
// that are NOT already covered by a malformed variable expression (pass 1).
|
||||
const openerStack: number[] = [];
|
||||
for (let i = 0; i < value.length - 1; i++) {
|
||||
if (value[i] === '{' && value[i + 1] === '{') {
|
||||
openerStack.push(i);
|
||||
i++; // skip second '{' so we don't double-count
|
||||
} else if (value[i] === '}' && value[i + 1] === '}') {
|
||||
if (openerStack.length > 0) {
|
||||
openerStack.pop();
|
||||
}
|
||||
i++; // skip second '}'
|
||||
}
|
||||
}
|
||||
// openerStack now contains the positions of every unmatched "{{".
|
||||
for (const idx of openerStack) {
|
||||
if (!malformedVarOpenerPositions.has(idx)) {
|
||||
const fragment = value.slice(idx, Math.min(idx + 40, value.length));
|
||||
results.push({ raw: fragment, location });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all template tokens from all interpolatable fields of a REST action.
|
||||
* Returns { tokens, malformed }.
|
||||
*/
|
||||
export function extractTemplates(action: RestAction): {
|
||||
tokens: TemplateToken[];
|
||||
malformed: MalformedToken[];
|
||||
} {
|
||||
const tokens: TemplateToken[] = [];
|
||||
const malformed: MalformedToken[] = [];
|
||||
|
||||
function scan(value: string, location: string) {
|
||||
tokens.push(...extractFromString(value, location));
|
||||
malformed.push(...detectMalformed(value, location));
|
||||
}
|
||||
|
||||
scan(action.url, 'url');
|
||||
|
||||
if (action.bodyTemplate) {
|
||||
scan(action.bodyTemplate, 'bodyTemplate');
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(action.headers ?? {})) {
|
||||
scan(val, `headers.${key}`);
|
||||
}
|
||||
for (const [key, val] of Object.entries(action.queryParameters ?? {})) {
|
||||
scan(val, `queryParameters.${key}`);
|
||||
}
|
||||
for (const [key, val] of Object.entries(action.pathParameters ?? {})) {
|
||||
scan(val, `pathParameters.${key}`);
|
||||
}
|
||||
|
||||
// De-duplicate identical token.raw+location combinations
|
||||
const seen = new Set<string>();
|
||||
const dedupedTokens = tokens.filter((t) => {
|
||||
const k = `${t.location}::${t.raw}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
|
||||
return { tokens: dedupedTokens, malformed };
|
||||
}
|
||||
|
||||
// ── Runtime interpolation ─────────────────────────────────────────────────────
|
||||
|
||||
type ComponentRuntimeState = {
|
||||
/**
|
||||
* TextInput: current typed string value.
|
||||
* Used for {{components.<name>.value}} template interpolation.
|
||||
*/
|
||||
textValue?: string;
|
||||
/**
|
||||
* Dropdown: current selected option value (string).
|
||||
* Also used for {{components.<name>.value}} template interpolation.
|
||||
* Label/JsonViewer: runtime display value (may be non-string).
|
||||
*/
|
||||
value?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a runtime variable value to a string for template insertion.
|
||||
*
|
||||
* Conversion rules (Step 18.1):
|
||||
* undefined → ""
|
||||
* null → "null"
|
||||
* string → original string
|
||||
* number → String(value)
|
||||
* boolean → String(value)
|
||||
* object → JSON.stringify(value)
|
||||
* array → JSON.stringify(value)
|
||||
*/
|
||||
function variableValueToString(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
// object or array
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates all {{components.<name>.value}} and {{variables.<name>}}
|
||||
* placeholders in a string.
|
||||
*
|
||||
* Throws if a referenced component name is not in componentsByName.
|
||||
* Throws if a referenced variable name is not in variableState (undeclared).
|
||||
* Declared variables with undefined runtime values resolve to "".
|
||||
*
|
||||
* Resolution order (per component):
|
||||
* 1. textValue (TextInput — Step 16 original behaviour)
|
||||
* 2. value cast to string (Dropdown — Step 17.2)
|
||||
* 3. "" (empty string) when neither is set
|
||||
*/
|
||||
export function interpolateString(
|
||||
template: string,
|
||||
componentsByName: Map<string, string>,
|
||||
componentState: Record<string, ComponentRuntimeState>,
|
||||
variableState: Record<string, unknown>,
|
||||
declaredVariableNames: Set<string>,
|
||||
): string {
|
||||
// Replace component placeholders first
|
||||
let result = template.replace(
|
||||
/\{\{components\.([^}]+)\.value\}\}/g,
|
||||
(_match, name: string) => {
|
||||
if (!componentsByName.has(name)) {
|
||||
throw new Error(
|
||||
`Template references component "${name}" which does not exist in the project.`,
|
||||
);
|
||||
}
|
||||
const id = componentsByName.get(name)!;
|
||||
const state = componentState[id];
|
||||
if (state?.textValue !== undefined) return state.textValue;
|
||||
if (state?.value !== undefined && state.value !== null) return String(state.value);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
// ── Malformed variable expressions — reject before any substitution ─────────
|
||||
// Any {{variables. occurrence that is not valid grammar is a configuration error.
|
||||
// classifyVariableExpression works on the whole result string; scan for each
|
||||
// {{variables. prefix individually so we can surface the exact expression.
|
||||
const malformedVarRe = /\{\{variables\.[^}]*\}?\}?/g;
|
||||
let mv: RegExpExecArray | null;
|
||||
malformedVarRe.lastIndex = 0;
|
||||
while ((mv = malformedVarRe.exec(result)) !== null) {
|
||||
const candidate = mv[0];
|
||||
if (classifyVariableExpression(candidate) === 'malformed') {
|
||||
throw new Error(
|
||||
`Malformed variable template "${candidate}": ` +
|
||||
`expected {{variables.<name>}} where <name> contains no dots. ` +
|
||||
`This is a configuration error — the action cannot execute.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace variable placeholders — name must not contain dots (Step 18.1 grammar).
|
||||
// Using [^}.] mirrors the extractor's VARIABLE_RE so runtime and Inspector agree.
|
||||
result = result.replace(
|
||||
/\{\{variables\.([^}.]+)\}\}/g,
|
||||
(_match, name: string) => {
|
||||
if (!declaredVariableNames.has(name)) {
|
||||
throw new Error(
|
||||
`Template references variable "${name}" which is not declared in project.variables.`,
|
||||
);
|
||||
}
|
||||
return variableValueToString(variableState[name]);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function interpolateRecord(
|
||||
record: Record<string, string>,
|
||||
componentsByName: Map<string, string>,
|
||||
componentState: Record<string, ComponentRuntimeState>,
|
||||
variableState: Record<string, unknown>,
|
||||
declaredVariableNames: Set<string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(record)) {
|
||||
result[key] = interpolateString(val, componentsByName, componentState, variableState, declaredVariableNames);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rendered copy of the action with all templates resolved.
|
||||
* Throws if any referenced component or undeclared variable is found.
|
||||
* The original action is never mutated.
|
||||
*
|
||||
* pathParameters are not rendered — they are forwarded verbatim to the backend
|
||||
* proxy which applies its own simpler substitution. Variable interpolation in
|
||||
* pathParameters is explicitly excluded in Step 18.1.
|
||||
*/
|
||||
export function renderAction(
|
||||
action: RestAction,
|
||||
componentsByName: Map<string, string>,
|
||||
componentState: Record<string, ComponentRuntimeState>,
|
||||
variableState: Record<string, unknown>,
|
||||
declaredVariableNames: Set<string>,
|
||||
): RestAction {
|
||||
return {
|
||||
...action,
|
||||
url: interpolateString(action.url, componentsByName, componentState, variableState, declaredVariableNames),
|
||||
headers: action.headers
|
||||
? interpolateRecord(action.headers, componentsByName, componentState, variableState, declaredVariableNames)
|
||||
: action.headers,
|
||||
queryParameters: action.queryParameters
|
||||
? interpolateRecord(action.queryParameters, componentsByName, componentState, variableState, declaredVariableNames)
|
||||
: action.queryParameters,
|
||||
bodyTemplate: action.bodyTemplate
|
||||
? interpolateString(action.bodyTemplate, componentsByName, componentState, variableState, declaredVariableNames)
|
||||
: action.bodyTemplate,
|
||||
};
|
||||
}
|
||||
825
frontend/src/components/Preview/usePreviewRuntime.ts
Normal file
825
frontend/src/components/Preview/usePreviewRuntime.ts
Normal file
@ -0,0 +1,825 @@
|
||||
/**
|
||||
* usePreviewRuntime
|
||||
*
|
||||
* Owns all runtime state for Preview Mode:
|
||||
* - per-component dynamic values (e.g. the JSON Viewer's displayed content)
|
||||
* - per-action runtime state (response, loading, error)
|
||||
* - per-button loading / error state
|
||||
* - per-variable runtime values (Step 18.1)
|
||||
* - the click handler that resolves bindings, calls the proxy, and writes
|
||||
* the result to target components and variables via response-mapping bindings
|
||||
* - TextInput value tracking so template interpolation can read them
|
||||
*
|
||||
* This hook is intentionally decoupled from the project store so Preview Mode
|
||||
* has its own ephemeral state that does not mutate the design-time document.
|
||||
*
|
||||
* ── Binding model (schema-conformant) ────────────────────────────────────────
|
||||
*
|
||||
* The schema uses a two-part model for button-click → REST → viewer wiring:
|
||||
*
|
||||
* 1. Component events array (component.events[]):
|
||||
* { "event": "onClick", "actionId": "<actionId>" }
|
||||
* Declares that clicking this component fires the named action.
|
||||
*
|
||||
* 2. Project-level bindings (project.bindings[]):
|
||||
* { "id": "...", "source": "actions.<actionId>.response.body",
|
||||
* "target": "components.<viewerName>.value", "trigger": "onSuccess" }
|
||||
* Routes the action response (or a field of it) to a target component.
|
||||
*
|
||||
* This is the same shape used in valid-response-mapping-basic.json.
|
||||
* Legacy Step 15 examples using trigger "onClick" remain compatible.
|
||||
*
|
||||
* ── Step 16: Input-to-Request template interpolation ─────────────────────────
|
||||
*
|
||||
* REST action fields (url, queryParameters values, headers values,
|
||||
* bodyTemplate) may contain {{components.<name>.value}} placeholders.
|
||||
* Before executing, the runtime resolves these against the current TextInput
|
||||
* values stored in componentState. The canonical project JSON is never
|
||||
* mutated — only an execution copy of the action is modified.
|
||||
*
|
||||
* Template utilities live in templateUtils.ts and are shared with
|
||||
* ActionInspector for Step 16.5 diagnostics.
|
||||
*
|
||||
* ── Step 17.1: Response mapping ───────────────────────────────────────────────
|
||||
*
|
||||
* After a successful proxy call, the runtime:
|
||||
* 1. Stores the full ProxyResponse in actionState[actionId].response
|
||||
* 2. Iterates project.bindings to find those whose source identifies the
|
||||
* completed action (e.g. "actions.<id>.response.body.origin")
|
||||
* 3. Resolves each source path against the stored response
|
||||
* 4. Writes resolved values to componentState[targetId].value
|
||||
*
|
||||
* Supported source paths: See docs/response-mapping-model.md §4
|
||||
* Supported target paths: components.<name>.value (JsonViewer, Label)
|
||||
* variables.<name> (Step 18.1)
|
||||
* Supported triggers: onSuccess (canonical), onClick (legacy compat)
|
||||
*
|
||||
* Canonical project JSON is never mutated during execution.
|
||||
*
|
||||
* ── Step 18.1: Runtime variables ─────────────────────────────────────────────
|
||||
*
|
||||
* Variables are initialized from project.variables.defaultValue on every
|
||||
* Preview mount. Runtime changes (from response bindings) do not mutate the
|
||||
* canonical project document. The variable runtime state is local to this
|
||||
* hook instance; leaving and re-entering Preview resets to configured defaults.
|
||||
*
|
||||
* Action request templates may reference {{variables.<name>}} placeholders.
|
||||
* Undeclared variable names in templates are configuration errors — the action
|
||||
* is not executed and Preview surfaces a useful error message.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Binding, CanvasComponent, ProjectDocument, RestAction } from '../../types/project';
|
||||
import type { ProxyResponse } from '../../api/proxyApi';
|
||||
import { executeAction } from '../../api/proxyApi';
|
||||
import { renderAction } from './templateUtils';
|
||||
import {
|
||||
type ActionRuntimeState,
|
||||
type ActionRuntimeStateMap,
|
||||
isTargetPropertySupported,
|
||||
classifyTrigger,
|
||||
parseActionSourcePath,
|
||||
parseComponentTargetPath,
|
||||
parseVariableTargetPath,
|
||||
resolveActionSource,
|
||||
normalizeDropdownOptions,
|
||||
reconcileDropdownSelection,
|
||||
normalizeTableRows,
|
||||
} from './bindingUtils';
|
||||
import { type VariableRuntimeState, initializeVariableState } from './variableUtils';
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ephemeral runtime state for a single component.
|
||||
* - JsonViewer uses `value` to display action responses.
|
||||
* - Label uses `value` to override its configured label text.
|
||||
* - TextInput uses `textValue` to track the current typed value.
|
||||
* - Dropdown uses `options` to override configured static options,
|
||||
* and `value` to track the current selection.
|
||||
*/
|
||||
export type ComponentRuntimeState = {
|
||||
/** Current display value (JSON-serialisable). Shown instead of defaultValue / label. */
|
||||
value?: unknown;
|
||||
/** Current string value of a TextInput component. */
|
||||
textValue?: string;
|
||||
/** Runtime options override for a Dropdown (replaces configured options when defined). */
|
||||
options?: import('../../types/project').DropdownOption[];
|
||||
/**
|
||||
* Runtime rows for a Table component.
|
||||
* When defined (including an empty array), overrides configured properties.rows.
|
||||
* Cleared when rows are replaced by a response binding (Step 17.5).
|
||||
* Never persisted to canonical project JSON.
|
||||
*/
|
||||
rows?: import('../../types/project').TableRow[];
|
||||
/** True while a bound action is in-flight for this component. */
|
||||
loading?: boolean;
|
||||
/** Error message to display when the last action for this component failed. */
|
||||
error?: string;
|
||||
/** Selected row index for Table components (runtime only, never persisted). */
|
||||
selectedIndex?: number;
|
||||
/** Selected row data for Table components (runtime only, never persisted). */
|
||||
selectedRow?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PreviewRuntime = {
|
||||
/**
|
||||
* Map from component ID → ephemeral runtime state.
|
||||
* Consumers read this to override design-time defaultValue / label.
|
||||
*/
|
||||
componentState: Record<string, ComponentRuntimeState>;
|
||||
|
||||
/**
|
||||
* Loading state keyed by Button component ID.
|
||||
* True while the button's bound action is in-flight.
|
||||
*/
|
||||
buttonLoading: Record<string, boolean>;
|
||||
|
||||
/**
|
||||
* Ephemeral action runtime state keyed by action ID.
|
||||
* Stores the latest ProxyResponse for each executed action.
|
||||
*/
|
||||
actionState: ActionRuntimeStateMap;
|
||||
|
||||
/**
|
||||
* Ephemeral runtime variable state keyed by variable name. (Step 18.1)
|
||||
* Initialized from project.variables.defaultValue on Preview mount.
|
||||
* Updated by onSuccess response bindings targeting variables.<name>.
|
||||
* Never persisted to the canonical project document or SQLite.
|
||||
*/
|
||||
variableState: VariableRuntimeState;
|
||||
|
||||
/**
|
||||
* Call this when a Button is clicked in Preview Mode.
|
||||
* Reads the component's events[] to find which action to execute, then reads
|
||||
* project.bindings[] to route the response to target components and variables.
|
||||
*/
|
||||
handleButtonClick: (buttonId: string) => void;
|
||||
|
||||
/**
|
||||
* Call this when a TextInput value changes in Preview Mode.
|
||||
* Stores the value in componentState so template interpolation can read it.
|
||||
*/
|
||||
handleTextInputChange: (componentId: string, value: string) => void;
|
||||
|
||||
/**
|
||||
* Call this when a Dropdown selection changes in Preview Mode.
|
||||
* Stores the selected option value in componentState.value so
|
||||
* {{components.<name>.value}} template interpolation can read it.
|
||||
* Also fires any onChange component events declared on the Dropdown.
|
||||
*/
|
||||
handleDropdownChange: (componentId: string, value: string) => void;
|
||||
|
||||
/**
|
||||
* Call this when a Table row is clicked in Preview Mode.
|
||||
* Stores selectedIndex and selectedRow in componentState.
|
||||
* Disabled Tables do not change selection.
|
||||
*/
|
||||
handleTableRowSelect: (componentId: string, index: number, row: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
// ── Re-export for consumers that only import from this module ─────────────────
|
||||
|
||||
export type { ActionRuntimeState, ActionRuntimeStateMap };
|
||||
|
||||
// ── Internal: apply response bindings ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* After a successful proxy call, iterates project.bindings and applies any
|
||||
* response-mapping bindings for the completed action.
|
||||
*
|
||||
* Returns a partial componentState update (may be empty if no bindings match).
|
||||
* Console-warns on mapping failures rather than throwing.
|
||||
*
|
||||
* This function is pure (no side effects other than console warnings) — the
|
||||
* caller applies the returned updates to React state.
|
||||
*
|
||||
* currentComponentState is used for selection reconciliation on .options targets.
|
||||
*/
|
||||
type ApplyResponseBindingsResult = {
|
||||
componentUpdates: Record<string, ComponentRuntimeState>;
|
||||
variableUpdates: VariableRuntimeState;
|
||||
};
|
||||
|
||||
function applyResponseBindings(
|
||||
actionId: string,
|
||||
proxyResponse: ProxyResponse,
|
||||
bindings: Binding[],
|
||||
allComponents: CanvasComponent[],
|
||||
updatedActionState: ActionRuntimeStateMap,
|
||||
currentComponentState: Record<string, ComponentRuntimeState>,
|
||||
declaredVariableNames: Set<string>,
|
||||
): ApplyResponseBindingsResult {
|
||||
// Build name → component map (used for target resolution)
|
||||
const byName = new Map<string, CanvasComponent[]>();
|
||||
for (const c of allComponents) {
|
||||
const list = byName.get(c.name) ?? [];
|
||||
list.push(c);
|
||||
byName.set(c.name, list);
|
||||
}
|
||||
|
||||
const componentUpdates: Record<string, ComponentRuntimeState> = {};
|
||||
const variableUpdates: VariableRuntimeState = {};
|
||||
|
||||
for (const binding of bindings) {
|
||||
// ── 1. Parse and match source ──────────────────────────────────────────
|
||||
const parsedSource = parseActionSourcePath(binding.source);
|
||||
|
||||
// Not an action-response source — skip
|
||||
if (!parsedSource) continue;
|
||||
|
||||
// Not for this action — skip (exact ID match, no substring)
|
||||
if (parsedSource.actionId !== actionId) continue;
|
||||
|
||||
// ── 2. Parse target — must happen before trigger diagnostics so that
|
||||
// variable targets and component targets can be handled separately.
|
||||
const parsedVariableTarget = parseVariableTargetPath(binding.target);
|
||||
const parsedTarget = parseComponentTargetPath(binding.target);
|
||||
|
||||
// ── 3. Trigger check — branched by target type ────────────────────────
|
||||
const triggerClass = classifyTrigger(binding.trigger);
|
||||
|
||||
if (parsedVariableTarget) {
|
||||
// Variable targets require onSuccess exactly — onClick (legacy) is not
|
||||
// accepted here, and unsupported triggers also get a variable-specific
|
||||
// message instead of the generic component-binding message.
|
||||
if (triggerClass !== 'onSuccess') {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": variable response bindings require trigger ` +
|
||||
`"onSuccess". Got "${binding.trigger ?? 'onChange'}". ` +
|
||||
`Binding skipped. Change the trigger to "onSuccess".`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Component target (or malformed target) — existing trigger behavior.
|
||||
if (triggerClass === 'unsupported') {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": trigger "${binding.trigger ?? 'onChange'}" is not ` +
|
||||
`supported for action-response bindings. Supported: onSuccess, onClick (legacy). ` +
|
||||
`Binding skipped.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Legacy onClick: compatible but log an info nudge (once per binding)
|
||||
if (triggerClass === 'onClick-legacy') {
|
||||
console.info(
|
||||
`[Preview] Binding "${binding.id}": trigger "onClick" is a legacy response-mapping ` +
|
||||
`trigger. Consider migrating to "onSuccess".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// onSuccess only fires when ok === true (legacy onClick also respects this
|
||||
// since we only call applyResponseBindings on success paths)
|
||||
if (!proxyResponse.ok) continue;
|
||||
|
||||
// ── 4. Resolve source value ────────────────────────────────────────────
|
||||
const sourceResult = resolveActionSource(parsedSource, updatedActionState);
|
||||
|
||||
if (!sourceResult.found) {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}" source "${binding.source}": ${sourceResult.reason}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 5a. Variable target (Step 18.1) ───────────────────────────────────
|
||||
// triggerClass === 'onSuccess' is guaranteed here for variable targets
|
||||
// (any other trigger already continued above).
|
||||
|
||||
if (parsedVariableTarget) {
|
||||
const { variableName } = parsedVariableTarget;
|
||||
|
||||
// Only declared variables may be targeted
|
||||
if (!declaredVariableNames.has(variableName)) {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": target "variables.${variableName}" references ` +
|
||||
`a variable that is not declared in project.variables. ` +
|
||||
`Binding skipped. Declare the variable in project.variables first.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write to variable updates
|
||||
variableUpdates[variableName] = sourceResult.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 5b. Parse component target ─────────────────────────────────────────
|
||||
|
||||
if (!parsedTarget) {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": target "${binding.target}" is not a supported ` +
|
||||
`component path (components.<name>.<property>) or variable path (variables.<name>).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 5. Resolve target component ────────────────────────────────────────
|
||||
const candidates = byName.get(parsedTarget.componentName);
|
||||
|
||||
if (!candidates || candidates.length === 0) {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": target component "${parsedTarget.componentName}" ` +
|
||||
`does not exist on any page.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidates.length > 1) {
|
||||
const ids = candidates.map((c) => c.id).join(', ');
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": target component name ` +
|
||||
`"${parsedTarget.componentName}" is ambiguous — ${candidates.length} components share ` +
|
||||
`this name (ids: ${ids}). No component updated.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetComp = candidates[0];
|
||||
|
||||
// ── 6. Confirm component type supports the target property ────────────
|
||||
if (!isTargetPropertySupported(targetComp.type, parsedTarget.property)) {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": target property "${parsedTarget.property}" is not ` +
|
||||
`supported for component type "${targetComp.type}". ` +
|
||||
`Supported: .value → JsonViewer, Label; .options → Dropdown; .rows → Table. Binding skipped.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 7. Write to component state ────────────────────────────────────────
|
||||
|
||||
if (parsedTarget.property === 'rows') {
|
||||
// Rows target — normalize the source array into TableRow[]
|
||||
const normalizeResult = normalizeTableRows(sourceResult.value);
|
||||
|
||||
if (!normalizeResult.ok) {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": rows normalization failed — ` +
|
||||
`${normalizeResult.reason} Table "${parsedTarget.componentName}" unchanged.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingUpdate = componentUpdates[targetComp.id] ?? currentComponentState[targetComp.id] ?? {};
|
||||
componentUpdates[targetComp.id] = {
|
||||
...existingUpdate,
|
||||
rows: normalizeResult.rows,
|
||||
// Clear row selection — old selection may no longer be valid
|
||||
selectedIndex: undefined,
|
||||
selectedRow: undefined,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
};
|
||||
} else if (parsedTarget.property === 'options') {
|
||||
// Options target — normalize the source array, reconcile selection
|
||||
const normalizeResult = normalizeDropdownOptions(sourceResult.value);
|
||||
|
||||
if (!normalizeResult.ok) {
|
||||
console.warn(
|
||||
`[Preview] Binding "${binding.id}": options normalization failed — ` +
|
||||
`${normalizeResult.reason} Dropdown "${parsedTarget.componentName}" unchanged.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const warning of normalizeResult.warnings) {
|
||||
console.warn(`[Preview] Binding "${binding.id}" options: ${warning}`);
|
||||
}
|
||||
|
||||
const { options: normalizedOptions } = normalizeResult;
|
||||
|
||||
// Determine configured value for reconciliation
|
||||
const configuredValue =
|
||||
typeof targetComp.properties['value'] === 'string'
|
||||
? (targetComp.properties['value'] as string)
|
||||
: '';
|
||||
|
||||
// Merge with any in-progress updates for this component
|
||||
const existingUpdate = componentUpdates[targetComp.id] ?? currentComponentState[targetComp.id] ?? {};
|
||||
const reconciledValue = reconcileDropdownSelection(
|
||||
existingUpdate.value,
|
||||
configuredValue,
|
||||
normalizedOptions,
|
||||
);
|
||||
|
||||
componentUpdates[targetComp.id] = {
|
||||
...existingUpdate,
|
||||
options: normalizedOptions,
|
||||
value: reconciledValue,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
};
|
||||
} else {
|
||||
// Value target (JsonViewer, Label)
|
||||
componentUpdates[targetComp.id] = {
|
||||
...(componentUpdates[targetComp.id] ?? {}),
|
||||
value: sourceResult.value,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { componentUpdates, variableUpdates };
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
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>({});
|
||||
|
||||
// ── Step 18.1: Runtime variable state ─────────────────────────────────────
|
||||
// Separate from project.variables — never mutates the canonical document.
|
||||
// Initialized from defaultValue on mount; reset when doc.project.variables changes.
|
||||
const [variableState, setVariableState] = useState<VariableRuntimeState>(
|
||||
() => initializeVariableState(doc.project.variables),
|
||||
);
|
||||
|
||||
// Re-initialize if the variable configuration changes (e.g. JSON editor update).
|
||||
// Use a ref to compare identity so we don't re-run on every render.
|
||||
const variablesRef = useRef(doc.project.variables);
|
||||
useEffect(() => {
|
||||
if (variablesRef.current !== doc.project.variables) {
|
||||
variablesRef.current = doc.project.variables;
|
||||
setVariableState(initializeVariableState(doc.project.variables));
|
||||
}
|
||||
}, [doc.project.variables]);
|
||||
|
||||
const handleTextInputChange = useCallback((componentId: string, value: string) => {
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[componentId]: { ...prev[componentId], textValue: value },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(buttonId: string) => {
|
||||
const { pages, actions, bindings, variables } = doc.project;
|
||||
|
||||
// Collect all components across all pages
|
||||
const allComponents = pages.flatMap((p) => p.components);
|
||||
|
||||
// Build a name → id map for template interpolation (Step 16)
|
||||
const componentsByName = new Map<string, string>(
|
||||
allComponents.map((c) => [c.name, c.id]),
|
||||
);
|
||||
|
||||
// Build declared variable names set for template interpolation (Step 18.1)
|
||||
const currentDeclaredNames = new Set(Object.keys(variables));
|
||||
|
||||
// ── 1. Find the clicked button component ─────────────────────────────
|
||||
const buttonComponent = allComponents.find((c) => c.id === buttonId);
|
||||
if (!buttonComponent) {
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[buttonId]: { error: `Component "${buttonId}" not found in project.` },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Read the component's events[] for onClick handlers ────────────
|
||||
const clickEvents = (buttonComponent.events ?? []).filter(
|
||||
(e) => e.event === 'onClick',
|
||||
);
|
||||
|
||||
if (clickEvents.length === 0) {
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[buttonId]: {
|
||||
error:
|
||||
`Button "${buttonComponent.name}" has no onClick event configured. ` +
|
||||
`Add an events entry: { "event": "onClick", "actionId": "<actionId>" }`,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute each onClick action (in practice there is usually one)
|
||||
for (const clickEvent of clickEvents) {
|
||||
const actionId = clickEvent.actionId;
|
||||
|
||||
// ── 3. Find the REST action ────────────────────────────────────────
|
||||
const action: RestAction | undefined = actions.find((a) => a.id === actionId);
|
||||
if (!action) {
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[buttonId]: {
|
||||
error:
|
||||
`onClick event references action "${actionId}" which does not ` +
|
||||
`exist in project.actions.`,
|
||||
},
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 4. Find response-mapping bindings for this action ────────────
|
||||
// Identify target component IDs for loading-state marking.
|
||||
// (Actual binding execution happens after the proxy call.)
|
||||
const responseMappingTargetIds = new Set<string>();
|
||||
for (const binding of bindings) {
|
||||
const parsed = parseActionSourcePath(binding.source);
|
||||
if (!parsed || parsed.actionId !== actionId) continue;
|
||||
const tc = classifyTrigger(binding.trigger);
|
||||
if (tc === 'unsupported') continue;
|
||||
const parsedTarget = parseComponentTargetPath(binding.target);
|
||||
if (!parsedTarget) continue;
|
||||
const candidates = allComponents.filter((c) => c.name === parsedTarget.componentName);
|
||||
if (candidates.length === 1 && isTargetPropertySupported(candidates[0].type, parsedTarget.property)) {
|
||||
responseMappingTargetIds.add(candidates[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Mark loading state ──────────────────────────────────────────
|
||||
setButtonLoading((prev) => ({ ...prev, [buttonId]: true }));
|
||||
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 },
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 6. Render templates and execute (Steps 16, 18.1) ──────────────
|
||||
let renderedAction: RestAction;
|
||||
try {
|
||||
renderedAction = renderAction(action, componentsByName, componentState, variableState, currentDeclaredNames);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[buttonId]: { loading: false, error: message },
|
||||
}));
|
||||
setActionState((prev) => ({
|
||||
...prev,
|
||||
[actionId]: { loading: false, error: message },
|
||||
}));
|
||||
setButtonLoading((prev) => ({ ...prev, [buttonId]: false }));
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 7. Execute proxy request ──────────────────────────────────────
|
||||
executeAction(renderedAction)
|
||||
.then((proxyResponse) => {
|
||||
// Store full response envelope in action state
|
||||
const newActionEntry: ActionRuntimeState = {
|
||||
response: proxyResponse,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
};
|
||||
const updatedActionState: ActionRuntimeStateMap = {
|
||||
[actionId]: newActionEntry,
|
||||
};
|
||||
|
||||
setActionState((prev) => ({ ...prev, ...updatedActionState }));
|
||||
|
||||
// ── 8. Apply response-mapping bindings ─────────────────────
|
||||
if (proxyResponse.ok) {
|
||||
const { componentUpdates, variableUpdates } = applyResponseBindings(
|
||||
actionId,
|
||||
proxyResponse,
|
||||
bindings,
|
||||
allComponents,
|
||||
updatedActionState,
|
||||
componentState,
|
||||
currentDeclaredNames,
|
||||
);
|
||||
|
||||
if (Object.keys(componentUpdates).length > 0) {
|
||||
setComponentState((prev) => ({ ...prev, ...componentUpdates }));
|
||||
}
|
||||
|
||||
if (Object.keys(variableUpdates).length > 0) {
|
||||
setVariableState((prev) => ({ ...prev, ...variableUpdates }));
|
||||
}
|
||||
|
||||
// If no bindings mapped anywhere, surface the full response on
|
||||
// the button itself so the user sees feedback even without a binding.
|
||||
if (Object.keys(componentUpdates).length === 0 && Object.keys(variableUpdates).length === 0 && responseMappingTargetIds.size === 0) {
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[buttonId]: { value: proxyResponse, loading: false, error: undefined },
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Non-2xx from upstream: surface status on button as an error message
|
||||
const errMsg =
|
||||
`Action "${action.name}" returned HTTP ${proxyResponse.status} ` +
|
||||
`${proxyResponse.statusText}.`;
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[buttonId]: { loading: false, error: errMsg },
|
||||
}));
|
||||
// Clear loading on mapped targets
|
||||
const clearedTargets: Record<string, ComponentRuntimeState> = {};
|
||||
for (const tid of responseMappingTargetIds) {
|
||||
clearedTargets[tid] = { loading: false };
|
||||
}
|
||||
if (Object.keys(clearedTargets).length > 0) {
|
||||
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionState((prev) => ({
|
||||
...prev,
|
||||
[actionId]: { loading: false, error: message },
|
||||
}));
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[buttonId]: { loading: false, error: message },
|
||||
}));
|
||||
// Clear loading on mapped targets
|
||||
const clearedTargets: Record<string, ComponentRuntimeState> = {};
|
||||
for (const tid of responseMappingTargetIds) {
|
||||
clearedTargets[tid] = { loading: false };
|
||||
}
|
||||
if (Object.keys(clearedTargets).length > 0) {
|
||||
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setButtonLoading((prev) => ({ ...prev, [buttonId]: false }));
|
||||
});
|
||||
}
|
||||
},
|
||||
[doc.project, componentState, variableState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Dropdown onChange handler.
|
||||
*
|
||||
* 1. Stores the selected option value in componentState[id].value so:
|
||||
* - DropdownRenderer reflects the selection immediately
|
||||
* - {{components.<name>.value}} template interpolation resolves it
|
||||
*
|
||||
* 2. Fires any onChange component events declared on the Dropdown, using the
|
||||
* same action-execution path as handleButtonClick. This reuses the
|
||||
* existing proxy call logic — only the triggering event differs.
|
||||
*/
|
||||
const handleDropdownChange = useCallback(
|
||||
(dropdownId: string, selectedValue: string) => {
|
||||
// 1. Update runtime state immediately
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[dropdownId]: { ...prev[dropdownId], value: selectedValue },
|
||||
}));
|
||||
|
||||
// 2. Fire onChange component events
|
||||
const { pages, actions, bindings, variables } = doc.project;
|
||||
const allComponents = pages.flatMap((p) => p.components);
|
||||
const dropdownComponent = allComponents.find((c) => c.id === dropdownId);
|
||||
if (!dropdownComponent) return;
|
||||
|
||||
const changeEvents = (dropdownComponent.events ?? []).filter(
|
||||
(e) => e.event === 'onChange',
|
||||
);
|
||||
if (changeEvents.length === 0) return;
|
||||
|
||||
const componentsByName = new Map<string, string>(
|
||||
allComponents.map((c) => [c.name, c.id]),
|
||||
);
|
||||
|
||||
const dropdownDeclaredNames = new Set(Object.keys(variables));
|
||||
|
||||
for (const changeEvent of changeEvents) {
|
||||
const actionId = changeEvent.actionId;
|
||||
const action = actions.find((a) => a.id === actionId);
|
||||
if (!action) {
|
||||
console.warn(
|
||||
`[Preview] Dropdown "${dropdownComponent.name}" onChange event references ` +
|
||||
`action "${actionId}" which does not exist in project.actions.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build the component state snapshot including the just-selected value
|
||||
// so template interpolation sees the new selection.
|
||||
const stateWithSelection = {
|
||||
...componentState,
|
||||
[dropdownId]: { ...(componentState[dropdownId] ?? {}), value: selectedValue },
|
||||
};
|
||||
|
||||
// Identify response-mapping targets for loading state
|
||||
const responseMappingTargetIds = new Set<string>();
|
||||
for (const binding of bindings) {
|
||||
const parsed = parseActionSourcePath(binding.source);
|
||||
if (!parsed || parsed.actionId !== actionId) continue;
|
||||
const tc = classifyTrigger(binding.trigger);
|
||||
if (tc === 'unsupported') continue;
|
||||
const parsedTarget = parseComponentTargetPath(binding.target);
|
||||
if (!parsedTarget) continue;
|
||||
const candidates = allComponents.filter((c) => c.name === parsedTarget.componentName);
|
||||
if (candidates.length === 1 && isTargetPropertySupported(candidates[0].type, parsedTarget.property)) {
|
||||
responseMappingTargetIds.add(candidates[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
setActionState((prev) => ({
|
||||
...prev,
|
||||
[actionId]: { ...prev[actionId], loading: true, error: undefined },
|
||||
}));
|
||||
for (const tid of responseMappingTargetIds) {
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[tid]: { ...prev[tid], loading: true, error: undefined },
|
||||
}));
|
||||
}
|
||||
|
||||
let renderedAction: RestAction;
|
||||
try {
|
||||
renderedAction = renderAction(action, componentsByName, stateWithSelection, variableState, dropdownDeclaredNames);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionState((prev) => ({
|
||||
...prev,
|
||||
[actionId]: { loading: false, error: message },
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
executeAction(renderedAction)
|
||||
.then((proxyResponse) => {
|
||||
const newActionEntry: ActionRuntimeState = {
|
||||
response: proxyResponse,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
};
|
||||
const updatedActionState: ActionRuntimeStateMap = { [actionId]: newActionEntry };
|
||||
setActionState((prev) => ({ ...prev, ...updatedActionState }));
|
||||
|
||||
if (proxyResponse.ok) {
|
||||
const { componentUpdates, variableUpdates } = applyResponseBindings(
|
||||
actionId,
|
||||
proxyResponse,
|
||||
bindings,
|
||||
allComponents,
|
||||
updatedActionState,
|
||||
stateWithSelection,
|
||||
dropdownDeclaredNames,
|
||||
);
|
||||
if (Object.keys(componentUpdates).length > 0) {
|
||||
setComponentState((prev) => ({ ...prev, ...componentUpdates }));
|
||||
}
|
||||
if (Object.keys(variableUpdates).length > 0) {
|
||||
setVariableState((prev) => ({ ...prev, ...variableUpdates }));
|
||||
}
|
||||
} else {
|
||||
const clearedTargets: Record<string, ComponentRuntimeState> = {};
|
||||
for (const tid of responseMappingTargetIds) {
|
||||
clearedTargets[tid] = { loading: false };
|
||||
}
|
||||
if (Object.keys(clearedTargets).length > 0) {
|
||||
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionState((prev) => ({
|
||||
...prev,
|
||||
[actionId]: { loading: false, error: message },
|
||||
}));
|
||||
const clearedTargets: Record<string, ComponentRuntimeState> = {};
|
||||
for (const tid of responseMappingTargetIds) {
|
||||
clearedTargets[tid] = { loading: false };
|
||||
}
|
||||
if (Object.keys(clearedTargets).length > 0) {
|
||||
setComponentState((prev) => ({ ...prev, ...clearedTargets }));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[doc.project, componentState, variableState],
|
||||
);
|
||||
|
||||
const handleTableRowSelect = useCallback(
|
||||
(tableId: string, index: number, row: Record<string, unknown>) => {
|
||||
setComponentState((prev) => ({
|
||||
...prev,
|
||||
[tableId]: { ...prev[tableId], selectedIndex: index, selectedRow: row },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
componentState,
|
||||
buttonLoading,
|
||||
actionState,
|
||||
variableState,
|
||||
handleButtonClick,
|
||||
handleTextInputChange,
|
||||
handleDropdownChange,
|
||||
handleTableRowSelect,
|
||||
};
|
||||
}
|
||||
76
frontend/src/components/Preview/variableUtils.ts
Normal file
76
frontend/src/components/Preview/variableUtils.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* variableUtils — Step 18.1
|
||||
*
|
||||
* Pure utilities for runtime variable state management.
|
||||
* Used by usePreviewRuntime and testable in isolation.
|
||||
*
|
||||
* Runtime variable state is a plain Record<string, unknown> that lives
|
||||
* inside the Preview hook. It is:
|
||||
* - initialized from project.variables on every Preview mount
|
||||
* - reset to configured defaults when Preview is unmounted and remounted
|
||||
* - never written back to the canonical project document
|
||||
* - never persisted to SQLite
|
||||
*
|
||||
* The canonical project.variables object is never mutated here.
|
||||
*/
|
||||
|
||||
import type { Variable } from '../../types/project';
|
||||
|
||||
/** Ephemeral runtime variable state. Keys are variable names; values are
|
||||
* the current runtime values (may differ from the configured defaultValue
|
||||
* after response bindings have run). */
|
||||
export type VariableRuntimeState = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Recursively clones a JSON-compatible value so that the returned copy shares
|
||||
* no references with the original.
|
||||
*
|
||||
* Rules:
|
||||
* - `null` → `null` (preserved exactly; must NOT become undefined)
|
||||
* - primitive values → returned as-is (they are immutable by value)
|
||||
* - plain arrays → new array whose elements are each recursively cloned
|
||||
* - plain objects → new object whose values are each recursively cloned
|
||||
*
|
||||
* This is intentionally narrower than a general deep-clone — it handles only
|
||||
* the JSON-compatible shapes that can appear in project.variables.defaultValue.
|
||||
*/
|
||||
function cloneJsonValue(value: unknown): unknown {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'object') return value; // string, number, boolean, undefined
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(cloneJsonValue);
|
||||
}
|
||||
// Plain object
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
result[key] = cloneJsonValue((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an initial VariableRuntimeState from a project's variable declarations.
|
||||
*
|
||||
* Initialization rules (per variable):
|
||||
* - `defaultValue` property absent → runtime value is `undefined`
|
||||
* - `defaultValue: null` → runtime value is `null`
|
||||
* - any other configured value → runtime value is a deep clone of that value
|
||||
*
|
||||
* Object and array defaults are deep-cloned so that runtime mutations never
|
||||
* affect the canonical project.variables configuration.
|
||||
*
|
||||
* Note: do NOT use `?? undefined` — that would silently convert explicit `null`
|
||||
* to `undefined`, losing the distinction between "no default" and "null default".
|
||||
*
|
||||
* The canonical `variables` object is never mutated.
|
||||
*/
|
||||
export function initializeVariableState(
|
||||
variables: Record<string, Variable>,
|
||||
): VariableRuntimeState {
|
||||
const state: VariableRuntimeState = {};
|
||||
for (const [name, variable] of Object.entries(variables)) {
|
||||
// Use 'defaultValue' in variable to distinguish absence from null.
|
||||
state[name] = 'defaultValue' in variable ? cloneJsonValue(variable.defaultValue) : undefined;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
1579
frontend/src/components/Preview/variables.test.ts
Normal file
1579
frontend/src/components/Preview/variables.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
271
frontend/src/components/ProjectToolbar/ProjectToolbar.module.css
Normal file
271
frontend/src/components/ProjectToolbar/ProjectToolbar.module.css
Normal file
@ -0,0 +1,271 @@
|
||||
/* ── Toolbar strip ───────────────────────────────────────────────── */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
background: #1f2328;
|
||||
border-bottom: 1px solid #30363d;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nameArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nameButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 260px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.nameButton:hover {
|
||||
border-color: #484f58;
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
background: #0d1117;
|
||||
border: 1px solid #3b82d4;
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.idBadge {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ── Action buttons ──────────────────────────────────────────────── */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 5px 12px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #30363d;
|
||||
border-color: #8b949e;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background: #1f6feb;
|
||||
border-color: #1f6feb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btnPrimary:hover:not(:disabled) {
|
||||
background: #388bfd;
|
||||
border-color: #388bfd;
|
||||
}
|
||||
|
||||
/* ── Load picker overlay ─────────────────────────────────────────── */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.picker {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
width: 480px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pickerHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.pickerClose {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pickerClose:hover { background: #f0f0f0; }
|
||||
|
||||
.pickerEmpty {
|
||||
padding: 24px 16px;
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pickerList {
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pickerItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid #f0f2f4;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.pickerItem:hover { background: #f7f8fa; }
|
||||
|
||||
.pickerItemActive { background: #dbeafe; }
|
||||
.pickerItemActive:hover { background: #dbeafe; }
|
||||
|
||||
.pickerName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.pickerMeta {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-top: 2px;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ── Toast stack ─────────────────────────────────────────────────── */
|
||||
|
||||
.toastStack {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: slideIn 0.18s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(24px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.toast_success {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.toast_error {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.toast_info {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.toastMsg {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
padding: 0 2px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.toastClose:hover { opacity: 1; }
|
||||
171
frontend/src/components/ProjectToolbar/ProjectToolbar.tsx
Normal file
171
frontend/src/components/ProjectToolbar/ProjectToolbar.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import styles from './ProjectToolbar.module.css';
|
||||
|
||||
function ProjectToolbar(): React.ReactElement {
|
||||
const {
|
||||
projectRowId,
|
||||
projectName,
|
||||
setProjectName,
|
||||
isDirty,
|
||||
isSaving,
|
||||
isLoading,
|
||||
projectList,
|
||||
newProject,
|
||||
saveProject,
|
||||
loadProject,
|
||||
refreshProjectList,
|
||||
toasts,
|
||||
dismissToast,
|
||||
} = useProject();
|
||||
|
||||
const [showLoadPicker, setShowLoadPicker] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameInput, setNameInput] = useState(projectName);
|
||||
|
||||
// Keep name input in sync when context changes (e.g. after load)
|
||||
useEffect(() => {
|
||||
setNameInput(projectName);
|
||||
}, [projectName]);
|
||||
|
||||
const handleOpenLoad = async () => {
|
||||
await refreshProjectList();
|
||||
setShowLoadPicker(true);
|
||||
};
|
||||
|
||||
const handleLoad = async (id: number) => {
|
||||
setShowLoadPicker(false);
|
||||
await loadProject(id);
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
setEditingName(false);
|
||||
if (nameInput.trim() && nameInput.trim() !== projectName) {
|
||||
setProjectName(nameInput.trim());
|
||||
} else {
|
||||
setNameInput(projectName); // revert if empty or unchanged
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') { setNameInput(projectName); setEditingName(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Toolbar strip ─────────────────────────────────────────── */}
|
||||
<div className={styles.toolbar}>
|
||||
{/* Project name — click to rename */}
|
||||
<div className={styles.nameArea}>
|
||||
{editingName ? (
|
||||
<input
|
||||
className={styles.nameInput}
|
||||
value={nameInput}
|
||||
autoFocus
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
onBlur={handleNameBlur}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
maxLength={200}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className={styles.nameButton}
|
||||
onClick={() => setEditingName(true)}
|
||||
title="Click to rename project"
|
||||
>
|
||||
{projectName}
|
||||
{isDirty && <span className={styles.dirtyDot} title="Unsaved changes" />}
|
||||
</button>
|
||||
)}
|
||||
{projectRowId !== null && (
|
||||
<span className={styles.idBadge}>#{projectRowId}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{/* New */}
|
||||
<button
|
||||
className={styles.btn}
|
||||
onClick={() => newProject()}
|
||||
disabled={isLoading || isSaving}
|
||||
title="Create a new project (current unsaved changes will be lost)"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
|
||||
{/* Save */}
|
||||
<button
|
||||
className={[styles.btn, styles.btnPrimary].join(' ')}
|
||||
onClick={saveProject}
|
||||
disabled={isSaving || isLoading}
|
||||
title={projectRowId === null ? 'Save project to backend' : 'Update project on backend'}
|
||||
>
|
||||
{isSaving ? 'Saving…' : (projectRowId === null ? 'Save' : 'Update')}
|
||||
</button>
|
||||
|
||||
{/* Load */}
|
||||
<button
|
||||
className={styles.btn}
|
||||
onClick={handleOpenLoad}
|
||||
disabled={isLoading || isSaving}
|
||||
title="Load an existing project from the backend"
|
||||
>
|
||||
{isLoading ? 'Loading…' : 'Load'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Load picker overlay ────────────────────────────────────── */}
|
||||
{showLoadPicker && (
|
||||
<div className={styles.overlay} onClick={() => setShowLoadPicker(false)}>
|
||||
<div className={styles.picker} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.pickerHeader}>
|
||||
<span>Load Project</span>
|
||||
<button className={styles.pickerClose} onClick={() => setShowLoadPicker(false)}>✕</button>
|
||||
</div>
|
||||
{projectList.length === 0 ? (
|
||||
<p className={styles.pickerEmpty}>No projects found on the backend.</p>
|
||||
) : (
|
||||
<ul className={styles.pickerList}>
|
||||
{projectList.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
className={[
|
||||
styles.pickerItem,
|
||||
p.id === projectRowId ? styles.pickerItemActive : '',
|
||||
].join(' ')}
|
||||
onClick={() => handleLoad(p.id)}
|
||||
>
|
||||
<span className={styles.pickerName}>{p.name}</span>
|
||||
<span className={styles.pickerMeta}>
|
||||
#{p.id} · {new Date(p.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Toast notifications ────────────────────────────────────── */}
|
||||
{toasts.length > 0 && (
|
||||
<div className={styles.toastStack}>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={[styles.toast, styles[`toast_${t.kind}`]].join(' ')}
|
||||
>
|
||||
<span className={styles.toastMsg}>{t.message}</span>
|
||||
<button className={styles.toastClose} onClick={() => dismissToast(t.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectToolbar;
|
||||
@ -0,0 +1,33 @@
|
||||
.canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(#e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(90deg, #e5e7eb 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #8b949e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: 13px;
|
||||
color: #b1bac4;
|
||||
}
|
||||
87
frontend/src/components/VisualEditor/Canvas/Canvas.tsx
Normal file
87
frontend/src/components/VisualEditor/Canvas/Canvas.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useRef } from 'react';
|
||||
import type { CanvasComponent as CanvasComponentType } from '../../../types/project';
|
||||
import CanvasComponent from './CanvasComponent';
|
||||
import styles from './Canvas.module.css';
|
||||
import type { ComponentType } from '../../../types/project';
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CanvasProps = {
|
||||
components: CanvasComponentType[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onMove: (id: string, dx: number, dy: number) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onDrop: (type: ComponentType, x: number, y: number) => void;
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Canvas({
|
||||
components,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onMove,
|
||||
onRemove,
|
||||
onDrop,
|
||||
}: CanvasProps): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Drag-over / drop (from palette drag) ──────────────────────────────────
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const type = e.dataTransfer.getData('text/plain') as ComponentType;
|
||||
if (!type) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
onDrop(type, x, y);
|
||||
};
|
||||
|
||||
// ── Click on canvas background deselects ──────────────────────────────────
|
||||
const handleBackgroundClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === canvasRef.current) {
|
||||
onSelect(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleBackgroundClick}
|
||||
>
|
||||
{components.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyTitle}>Canvas is empty</p>
|
||||
<p className={styles.emptyHint}>
|
||||
Click a component in the palette to add it, or drag it here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{components.map((c) => (
|
||||
<CanvasComponent
|
||||
key={c.id}
|
||||
component={c}
|
||||
selected={c.id === selectedId}
|
||||
onSelect={onSelect}
|
||||
onMove={onMove}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Canvas;
|
||||
@ -0,0 +1,231 @@
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
cursor: grab;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
|
||||
user-select: none;
|
||||
touch-action: none; /* required for pointer capture */
|
||||
}
|
||||
|
||||
.wrapper:hover {
|
||||
border-color: #3b82d4;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: #3b82d4;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 212, 0.25);
|
||||
}
|
||||
|
||||
.wrapper:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Type badge ───────────────────────────────────────────────────── */
|
||||
|
||||
.typeBadge {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 3px 3px 0 0;
|
||||
padding: 0 5px;
|
||||
line-height: 17px;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Delete button ────────────────────────────────────────────────── */
|
||||
|
||||
.removeBtn {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
right: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 3px 3px 0 0;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.removeBtn:hover {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ── Content area ─────────────────────────────────────────────────── */
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Label renderer ───────────────────────────────────────────────── */
|
||||
|
||||
.labelText {
|
||||
font-size: 13px;
|
||||
color: #1f2328;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Button renderer ──────────────────────────────────────────────── */
|
||||
|
||||
.buttonInner {
|
||||
padding: 6px 16px;
|
||||
background: #3b82d4;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── TextInput renderer ───────────────────────────────────────────── */
|
||||
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inputLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inputInner {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #1f2328;
|
||||
background: #ffffff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── JsonViewer renderer ──────────────────────────────────────────── */
|
||||
|
||||
.jsonViewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jsonViewerHeader {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #8b949e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.jsonViewerPre {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: #1f2328;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ── Table renderer ───────────────────────────────────────────────── */
|
||||
|
||||
.tableContent {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 8px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.tableDisabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tableLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.tableTh {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 4px 8px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.tableTd {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
255
frontend/src/components/VisualEditor/Canvas/CanvasComponent.tsx
Normal file
255
frontend/src/components/VisualEditor/Canvas/CanvasComponent.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import React, { useRef } from 'react';
|
||||
import type { CanvasComponent as CanvasComponentType, TableColumn, TableRow } from '../../../types/project';
|
||||
import styles from './CanvasComponent.module.css';
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CanvasComponentProps = {
|
||||
component: CanvasComponentType;
|
||||
selected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onMove: (id: string, dx: number, dy: number) => void;
|
||||
onRemove: (id: string) => void;
|
||||
};
|
||||
|
||||
// ── Inner renderers per component type ───────────────────────────────────────
|
||||
|
||||
function LabelRenderer({ label }: { label: string }): React.ReactElement {
|
||||
return <span className={styles.labelText}>{label || 'Label'}</span>;
|
||||
}
|
||||
|
||||
function ButtonRenderer({ label }: { label: string }): React.ReactElement {
|
||||
return <button className={styles.buttonInner} tabIndex={-1}>{label || 'Button'}</button>;
|
||||
}
|
||||
|
||||
function TextInputRenderer({ label, placeholder }: { label: string; placeholder?: string }): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.inputWrapper}>
|
||||
{label && <label className={styles.inputLabel}>{label}</label>}
|
||||
<input
|
||||
className={styles.inputInner}
|
||||
type="text"
|
||||
placeholder={placeholder || 'Enter text…'}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonViewerRenderer(): React.ReactElement {
|
||||
const sample = JSON.stringify({ status: 'ok', data: { key: 'value' } }, null, 2);
|
||||
return (
|
||||
<div className={styles.jsonViewer}>
|
||||
<div className={styles.jsonViewerHeader}>JSON Viewer</div>
|
||||
<pre className={styles.jsonViewerPre}>{sample}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownRenderer({
|
||||
label,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.inputWrapper}>
|
||||
{label && <label className={styles.inputLabel}>{label}</label>}
|
||||
<select className={styles.inputInner} disabled tabIndex={-1}>
|
||||
<option value="">{placeholder || 'Select an option'}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRenderer({
|
||||
label,
|
||||
columns,
|
||||
rows,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
columns: TableColumn[];
|
||||
rows: TableRow[];
|
||||
disabled: boolean;
|
||||
}): React.ReactElement {
|
||||
// Derive columns from first-row keys if columns is empty
|
||||
const effectiveCols: TableColumn[] =
|
||||
columns.length > 0
|
||||
? columns
|
||||
: rows.length > 0
|
||||
? Object.keys(rows[0]).map((k) => ({ key: k, header: k }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={[styles.tableWrapper, disabled ? styles.tableDisabled : ''].join(' ')}>
|
||||
{label && <div className={styles.tableLabel}>{label}</div>}
|
||||
{effectiveCols.length === 0 && rows.length === 0 ? (
|
||||
<div className={styles.tableEmpty}>No data</div>
|
||||
) : (
|
||||
<div className={styles.tableScroll}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{effectiveCols.map((col) => (
|
||||
<th key={col.key} className={styles.tableTh}>{col.header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className={styles.tableTd}
|
||||
colSpan={effectiveCols.length || 1}
|
||||
style={{ textAlign: 'center', color: '#8b949e' }}
|
||||
>
|
||||
No rows
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{effectiveCols.map((col) => (
|
||||
<td key={col.key} className={styles.tableTd}>
|
||||
{renderCellValue(row[col.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Converts a cell value to a display string per the step spec. */
|
||||
function renderCellValue(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '[serialisation error]';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag-to-move state ────────────────────────────────────────────────────────
|
||||
|
||||
type DragState = {
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
};
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
function CanvasComponent({
|
||||
component,
|
||||
selected,
|
||||
onSelect,
|
||||
onMove,
|
||||
onRemove,
|
||||
}: CanvasComponentProps): React.ReactElement {
|
||||
const { id, type, position, size, properties } = component;
|
||||
const label = typeof properties.label === 'string' ? properties.label : '';
|
||||
const placeholder = typeof properties.placeholder === 'string' ? properties.placeholder : undefined;
|
||||
|
||||
const dragState = useRef<DragState | null>(null);
|
||||
|
||||
// ── Pointer-based drag-to-move ────────────────────────────────────────────
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
// Only drag on left button; don't initiate drag on the delete button
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('[data-remove]')) return;
|
||||
|
||||
e.stopPropagation();
|
||||
onSelect(id);
|
||||
|
||||
dragState.current = {
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
};
|
||||
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
const dx = e.clientX - dragState.current.startMouseX;
|
||||
const dy = e.clientY - dragState.current.startMouseY;
|
||||
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
||||
onMove(id, dx, dy);
|
||||
dragState.current = {
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
dragState.current = null;
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.wrapper, selected ? styles.selected : ''].join(' ')}
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: size.width,
|
||||
height: type === 'JsonViewer' || type === 'TextInput' || type === 'Table' ? 'auto' : size.height,
|
||||
minHeight: size.height,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(id); }}
|
||||
>
|
||||
{/* Component name badge */}
|
||||
<span className={styles.typeBadge}>{type}</span>
|
||||
|
||||
{/* Delete button — shown when selected */}
|
||||
{selected && (
|
||||
<button
|
||||
className={styles.removeBtn}
|
||||
data-remove="true"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
|
||||
title="Remove component"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={type === 'Table' ? styles.tableContent : styles.content}>
|
||||
{type === 'Label' && <LabelRenderer label={label} />}
|
||||
{type === 'Button' && <ButtonRenderer label={label} />}
|
||||
{type === 'TextInput' && <TextInputRenderer label={label} placeholder={placeholder} />}
|
||||
{type === 'JsonViewer' && <JsonViewerRenderer />}
|
||||
{type === 'Dropdown' && <DropdownRenderer label={label} placeholder={placeholder} />}
|
||||
{type === 'Table' && (
|
||||
<TableRenderer
|
||||
label={label}
|
||||
columns={Array.isArray(properties.columns) ? (properties.columns as TableColumn[]) : []}
|
||||
rows={Array.isArray(properties.rows) ? (properties.rows as TableRow[]) : []}
|
||||
disabled={properties.disabled === true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CanvasComponent;
|
||||
@ -0,0 +1,68 @@
|
||||
.palette {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
background: #f7f8fa;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 14px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #8b949e;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.item:active {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1f2328;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.itemDesc {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
line-height: 1.5;
|
||||
}
|
||||
53
frontend/src/components/VisualEditor/Palette/Palette.tsx
Normal file
53
frontend/src/components/VisualEditor/Palette/Palette.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import styles from './Palette.module.css';
|
||||
import type { ComponentType } from '../../../types/project';
|
||||
|
||||
// ── Palette item definitions ──────────────────────────────────────────────────
|
||||
|
||||
type PaletteItem = {
|
||||
type: ComponentType;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{ type: 'Label', label: 'Label', description: 'Static text' },
|
||||
{ type: 'Button', label: 'Button', description: 'Clickable action' },
|
||||
{ type: 'TextInput', label: 'Text Input', description: 'Single-line input' },
|
||||
{ type: 'JsonViewer', label: 'JSON Viewer', description: 'Formatted JSON' },
|
||||
{ type: 'Dropdown', label: 'Dropdown', description: 'Option selector' },
|
||||
{ type: 'Table', label: 'Table', description: 'Data table' },
|
||||
];
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PaletteProps = {
|
||||
onAdd: (type: ComponentType) => void;
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Palette({ onAdd }: PaletteProps): React.ReactElement {
|
||||
return (
|
||||
<aside className={styles.palette}>
|
||||
<div className={styles.header}>Components</div>
|
||||
<ul className={styles.list}>
|
||||
{PALETTE_ITEMS.map((item) => (
|
||||
<li key={item.type}>
|
||||
<button
|
||||
className={styles.item}
|
||||
onClick={() => onAdd(item.type)}
|
||||
title={`Add ${item.label} to canvas`}
|
||||
>
|
||||
<span className={styles.itemLabel}>{item.label}</span>
|
||||
<span className={styles.itemDesc}>{item.description}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.hint}>Click a component to add it to the canvas</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default Palette;
|
||||
166
frontend/src/components/VisualEditor/VisualEditor.module.css
Normal file
166
frontend/src/components/VisualEditor/VisualEditor.module.css
Normal file
@ -0,0 +1,166 @@
|
||||
/* The visual editor fills the entire main content area.
|
||||
Layout.module.css sets padding: 32px on <main>; we break out of it
|
||||
with negative margins so the editor gets a true full-bleed surface. */
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Pull back the 32px padding that Layout.module.css adds to <main> */
|
||||
margin: -32px;
|
||||
height: calc(100% + 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Loading overlay ─────────────────────────────────────────────── */
|
||||
|
||||
.loadingOverlay {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* ── Editor info bar (below project toolbar) ─────────────────────── */
|
||||
|
||||
.editorBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
background: #f7f8fa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.projectName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.pageName {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.componentCount {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Body: palette | canvas | info ───────────────────────────────── */
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Right info / JSON panel ─────────────────────────────────────── */
|
||||
|
||||
.info {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: #f7f8fa;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.infoHeader {
|
||||
padding: 10px 14px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #8b949e;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.infoBody {
|
||||
padding: 8px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.infoRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.infoKey {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.infoVal {
|
||||
font-size: 12px;
|
||||
color: #1f2328;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoInput {
|
||||
font-size: 12px;
|
||||
color: #1f2328;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
width: 120px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.infoInput:focus {
|
||||
border-color: #3b82d4;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 212, 0.18);
|
||||
}
|
||||
|
||||
.infoEmpty {
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
color: #b1bac4;
|
||||
}
|
||||
|
||||
/* ── Compact JSON read-out ────────────────────────────────────────── */
|
||||
|
||||
.jsonSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.jsonPre {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 14px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: #57606a;
|
||||
white-space: pre;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
607
frontend/src/components/VisualEditor/VisualEditor.tsx
Normal file
607
frontend/src/components/VisualEditor/VisualEditor.tsx
Normal file
@ -0,0 +1,607 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Palette from './Palette/Palette';
|
||||
import Canvas from './Canvas/Canvas';
|
||||
import ProjectToolbar from '../ProjectToolbar/ProjectToolbar';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import styles from './VisualEditor.module.css';
|
||||
import type { ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
|
||||
|
||||
// ── Dropdown options row editor ───────────────────────────────────────────────
|
||||
|
||||
type DropdownOptionsEditorProps = {
|
||||
componentId: string;
|
||||
options: DropdownOption[];
|
||||
configuredValue: string;
|
||||
onOptionsChange: (options: DropdownOption[]) => void;
|
||||
onConfiguredValueChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function DropdownOptionsEditor({
|
||||
options,
|
||||
configuredValue,
|
||||
onOptionsChange,
|
||||
onConfiguredValueChange,
|
||||
}: DropdownOptionsEditorProps): React.ReactElement {
|
||||
const [optionsError, setOptionsError] = useState<string | null>(null);
|
||||
|
||||
const handleAddOption = () => {
|
||||
onOptionsChange([...options, { label: '', value: '' }]);
|
||||
};
|
||||
|
||||
const handleChangeOption = (index: number, field: 'label' | 'value', text: string) => {
|
||||
const updated = options.map((o, i) =>
|
||||
i === index ? { ...o, [field]: text } : o,
|
||||
);
|
||||
onOptionsChange(updated);
|
||||
// Warn on duplicate values (non-blocking)
|
||||
const values = updated.map((o) => o.value).filter(Boolean);
|
||||
const hasDups = values.length !== new Set(values).size;
|
||||
setOptionsError(hasDups ? 'Duplicate option values detected.' : null);
|
||||
};
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
const updated = options.filter((_, i) => i !== index);
|
||||
onOptionsChange(updated);
|
||||
const values = updated.map((o) => o.value).filter(Boolean);
|
||||
const hasDups = values.length !== new Set(values).size;
|
||||
setOptionsError(hasDups ? 'Duplicate option values detected.' : null);
|
||||
};
|
||||
|
||||
// Warn if configured value is not in option list (non-blocking)
|
||||
const configuredValueValid =
|
||||
configuredValue === '' ||
|
||||
options.some((o) => o.value === configuredValue);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>Options</div>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 3, alignItems: 'center' }}>
|
||||
<input
|
||||
className={styles.infoInput}
|
||||
style={{ width: 70 }}
|
||||
placeholder="Label"
|
||||
value={opt.label}
|
||||
onChange={(e) => handleChangeOption(i, 'label', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={styles.infoInput}
|
||||
style={{ width: 70 }}
|
||||
placeholder="Value"
|
||||
value={opt.value}
|
||||
onChange={(e) => handleChangeOption(i, 'value', e.target.value)}
|
||||
/>
|
||||
<button
|
||||
style={{ fontSize: 10, padding: '2px 5px', cursor: 'pointer', background: 'none',
|
||||
border: '1px solid #d0d7de', borderRadius: 3, color: '#b91c1c', flexShrink: 0 }}
|
||||
onClick={() => handleRemoveOption(i)}
|
||||
title="Remove option"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{optionsError && (
|
||||
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{optionsError}</div>
|
||||
)}
|
||||
<button
|
||||
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
|
||||
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
|
||||
onClick={handleAddOption}
|
||||
>
|
||||
+ Add option
|
||||
</button>
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-dropdown-value">Default value</label>
|
||||
<input
|
||||
id="prop-dropdown-value"
|
||||
className={styles.infoInput}
|
||||
type="text"
|
||||
placeholder="e.g. dev"
|
||||
value={configuredValue}
|
||||
onChange={(e) => onConfiguredValueChange(e.target.value)}
|
||||
style={!configuredValueValid ? { borderColor: '#f59e0b' } : undefined}
|
||||
title={!configuredValueValid ? 'Value not found in options' : undefined}
|
||||
/>
|
||||
</div>
|
||||
{!configuredValueValid && (
|
||||
<div style={{ fontSize: 10, color: '#92400e', marginBottom: 4 }}>
|
||||
Default value not found in options.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table columns editor ──────────────────────────────────────────────────────
|
||||
|
||||
type TableColumnsEditorProps = {
|
||||
columns: TableColumn[];
|
||||
onColumnsChange: (columns: TableColumn[]) => void;
|
||||
};
|
||||
|
||||
function TableColumnsEditor({ columns, onColumnsChange }: TableColumnsEditorProps): React.ReactElement {
|
||||
const [colError, setColError] = useState<string | null>(null);
|
||||
|
||||
const validate = (cols: TableColumn[]): string | null => {
|
||||
const keys = cols.map((c) => c.key).filter(Boolean);
|
||||
const dupKeys = keys.filter((k, i) => keys.indexOf(k) !== i);
|
||||
if (dupKeys.length > 0) return `Duplicate column keys: ${dupKeys.map((k) => `"${k}"`).join(', ')}.`;
|
||||
const emptyKey = cols.some((c) => !c.key);
|
||||
if (emptyKey) return 'Column key must not be empty.';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const updated = [...columns, { key: '', header: '' }];
|
||||
onColumnsChange(updated);
|
||||
setColError(validate(updated));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, field: 'key' | 'header', value: string) => {
|
||||
const updated = columns.map((c, i) => i === index ? { ...c, [field]: value } : c);
|
||||
onColumnsChange(updated);
|
||||
setColError(validate(updated));
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const updated = columns.filter((_, i) => i !== index);
|
||||
onColumnsChange(updated);
|
||||
setColError(validate(updated));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>Columns</div>
|
||||
{columns.map((col, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 3, alignItems: 'center' }}>
|
||||
<input
|
||||
className={styles.infoInput}
|
||||
style={{ width: 70 }}
|
||||
placeholder="Label"
|
||||
value={col.header}
|
||||
onChange={(e) => handleChange(i, 'header', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={styles.infoInput}
|
||||
style={{ width: 70, borderColor: !col.key ? '#f59e0b' : undefined }}
|
||||
placeholder="Key"
|
||||
value={col.key}
|
||||
onChange={(e) => handleChange(i, 'key', e.target.value)}
|
||||
title={!col.key ? 'Key must not be empty' : undefined}
|
||||
/>
|
||||
<button
|
||||
style={{ fontSize: 10, padding: '2px 5px', cursor: 'pointer', background: 'none',
|
||||
border: '1px solid #d0d7de', borderRadius: 3, color: '#b91c1c', flexShrink: 0 }}
|
||||
onClick={() => handleRemove(i)}
|
||||
title="Remove column"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{colError && (
|
||||
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{colError}</div>
|
||||
)}
|
||||
<button
|
||||
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
|
||||
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
+ Add column
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table rows editor ─────────────────────────────────────────────────────────
|
||||
|
||||
type TableRowsEditorProps = {
|
||||
rows: TableRow[];
|
||||
onRowsChange: (rows: TableRow[]) => void;
|
||||
};
|
||||
|
||||
function TableRowsEditor({ rows, onRowsChange }: TableRowsEditorProps): React.ReactElement {
|
||||
// Draft text for the JSON textarea; initialise from current rows
|
||||
const [draft, setDraft] = useState<string>(() => JSON.stringify(rows, null, 2));
|
||||
const [rowsError, setRowsError] = useState<string | null>(null);
|
||||
|
||||
// Sync draft when rows prop changes externally (e.g. on component selection)
|
||||
const prevRowsRef = React.useRef<TableRow[]>(rows);
|
||||
if (rows !== prevRowsRef.current) {
|
||||
prevRowsRef.current = rows;
|
||||
setDraft(JSON.stringify(rows, null, 2));
|
||||
setRowsError(null);
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(draft);
|
||||
} catch (e: unknown) {
|
||||
setRowsError(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
setRowsError('Rows must be a JSON array.');
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const el = parsed[i];
|
||||
if (el === null || typeof el !== 'object' || Array.isArray(el)) {
|
||||
setRowsError(
|
||||
`Row at index ${i} must be a non-null object (got ${
|
||||
el === null ? 'null' : Array.isArray(el) ? 'array' : typeof el
|
||||
}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setRowsError(null);
|
||||
onRowsChange(parsed as TableRow[]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>
|
||||
Rows (JSON array)
|
||||
</div>
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%', minHeight: 80, fontFamily: 'monospace', fontSize: 11,
|
||||
border: '1px solid #d0d7de', borderRadius: 3, padding: '4px 6px',
|
||||
resize: 'vertical', boxSizing: 'border-box',
|
||||
borderColor: rowsError ? '#f59e0b' : undefined,
|
||||
}}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{rowsError && (
|
||||
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{rowsError}</div>
|
||||
)}
|
||||
<button
|
||||
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
|
||||
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply rows
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function VisualEditor(): React.ReactElement {
|
||||
const {
|
||||
doc,
|
||||
activePage,
|
||||
canvas,
|
||||
selectedId,
|
||||
selectComponent,
|
||||
isLoading,
|
||||
} = useProject();
|
||||
|
||||
const {
|
||||
addComponent,
|
||||
moveComponent,
|
||||
removeComponent,
|
||||
updateComponentProperty,
|
||||
updateComponentName,
|
||||
updateComponentSize,
|
||||
} = canvas;
|
||||
|
||||
const selectedComponent = activePage.components.find((x) => x.id === selectedId) ?? null;
|
||||
|
||||
// Local state for width/height text inputs (allows typing without snapping mid-edit)
|
||||
const [widthDraft, setWidthDraft] = useState<string>('');
|
||||
const [heightDraft, setHeightDraft] = useState<string>('');
|
||||
|
||||
// Sync drafts when selection changes
|
||||
const prevSelectedId = React.useRef<string | null>(null);
|
||||
if (selectedComponent && selectedComponent.id !== prevSelectedId.current) {
|
||||
prevSelectedId.current = selectedComponent.id;
|
||||
setWidthDraft(String(selectedComponent.size.width));
|
||||
setHeightDraft(String(selectedComponent.size.height));
|
||||
}
|
||||
if (!selectedComponent && prevSelectedId.current !== null) {
|
||||
prevSelectedId.current = null;
|
||||
}
|
||||
|
||||
// Duplicate-name detection
|
||||
const isDuplicateName = useCallback(
|
||||
(name: string, excludeId: string) =>
|
||||
activePage.components.some((c) => c.id !== excludeId && c.name === name),
|
||||
[activePage.components],
|
||||
);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(id: string, value: string) => {
|
||||
updateComponentName(id, value);
|
||||
},
|
||||
[updateComponentName],
|
||||
);
|
||||
|
||||
const handleWidthCommit = useCallback(
|
||||
(id: string, currentHeight: number) => {
|
||||
const n = parseInt(widthDraft, 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
updateComponentSize(id, n, currentHeight);
|
||||
} else {
|
||||
// Reset draft to actual value if invalid
|
||||
setWidthDraft(selectedComponent ? String(selectedComponent.size.width) : widthDraft);
|
||||
}
|
||||
},
|
||||
[widthDraft, updateComponentSize, selectedComponent],
|
||||
);
|
||||
|
||||
const handleHeightCommit = useCallback(
|
||||
(id: string, currentWidth: number) => {
|
||||
const n = parseInt(heightDraft, 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
updateComponentSize(id, currentWidth, n);
|
||||
} else {
|
||||
setHeightDraft(selectedComponent ? String(selectedComponent.size.height) : heightDraft);
|
||||
}
|
||||
},
|
||||
[heightDraft, updateComponentSize, selectedComponent],
|
||||
);
|
||||
|
||||
// Add from palette click — place at a staggered default position
|
||||
const handlePaletteAdd = useCallback(
|
||||
(type: ComponentType) => {
|
||||
const offset = activePage.components.length * 24;
|
||||
addComponent(type, 32 + offset, 32 + offset);
|
||||
},
|
||||
[activePage.components.length, addComponent],
|
||||
);
|
||||
|
||||
// Add from canvas drop
|
||||
const handleCanvasDrop = useCallback(
|
||||
(type: ComponentType, x: number, y: number) => {
|
||||
addComponent(type, x, y);
|
||||
},
|
||||
[addComponent],
|
||||
);
|
||||
|
||||
// Show a loading overlay while fetching a project from the backend
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.editor}>
|
||||
<ProjectToolbar />
|
||||
<div className={styles.loadingOverlay}>Loading project…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.editor}>
|
||||
{/* ── Project toolbar (New / Save / Load) ───────────────────── */}
|
||||
<ProjectToolbar />
|
||||
|
||||
{/* ── Editor toolbar (page info) ────────────────────────────── */}
|
||||
<div className={styles.editorBar}>
|
||||
<span className={styles.projectName}>{doc.project.name}</span>
|
||||
<span className={styles.pageName}>{activePage.name}</span>
|
||||
<span className={styles.componentCount}>
|
||||
{activePage.components.length} component
|
||||
{activePage.components.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Main area: palette | canvas | info ────────────────────── */}
|
||||
<div className={styles.body}>
|
||||
<Palette onAdd={handlePaletteAdd} />
|
||||
|
||||
<Canvas
|
||||
components={activePage.components}
|
||||
selectedId={selectedId}
|
||||
onSelect={selectComponent}
|
||||
onMove={moveComponent}
|
||||
onRemove={(id) => { removeComponent(id); selectComponent(null); }}
|
||||
onDrop={handleCanvasDrop}
|
||||
/>
|
||||
|
||||
{/* Selection info + live JSON */}
|
||||
<aside className={styles.info}>
|
||||
<div className={styles.infoHeader}>Selection</div>
|
||||
{selectedComponent ? (
|
||||
<div className={styles.infoBody}>
|
||||
{/* Type + position — read-only */}
|
||||
{([
|
||||
['Type', selectedComponent.type],
|
||||
['X', `${selectedComponent.position.x}px`],
|
||||
['Y', `${selectedComponent.position.y}px`],
|
||||
] as [string, string][]).map(([k, v]) => (
|
||||
<div key={k} className={styles.infoRow}>
|
||||
<span className={styles.infoKey}>{k}</span>
|
||||
<span className={styles.infoVal}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Editable name */}
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-name">Name</label>
|
||||
<input
|
||||
id="prop-name"
|
||||
className={styles.infoInput}
|
||||
type="text"
|
||||
value={selectedComponent.name}
|
||||
onChange={(e) => handleNameChange(selectedComponent.id, e.target.value)}
|
||||
style={
|
||||
selectedComponent.name === '' ||
|
||||
isDuplicateName(selectedComponent.name, selectedComponent.id)
|
||||
? { borderColor: '#f59e0b' }
|
||||
: undefined
|
||||
}
|
||||
title={
|
||||
selectedComponent.name === ''
|
||||
? 'Name must not be empty'
|
||||
: isDuplicateName(selectedComponent.name, selectedComponent.id)
|
||||
? 'Duplicate name — components must have unique names'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{selectedComponent.name === '' && (
|
||||
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 2 }}>
|
||||
Name must not be empty.
|
||||
</div>
|
||||
)}
|
||||
{selectedComponent.name !== '' &&
|
||||
isDuplicateName(selectedComponent.name, selectedComponent.id) && (
|
||||
<div style={{ fontSize: 10, color: '#92400e', marginBottom: 2 }}>
|
||||
Duplicate name detected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editable width */}
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-width">W</label>
|
||||
<input
|
||||
id="prop-width"
|
||||
className={styles.infoInput}
|
||||
type="number"
|
||||
min={1}
|
||||
value={widthDraft}
|
||||
onChange={(e) => setWidthDraft(e.target.value)}
|
||||
onBlur={() => handleWidthCommit(selectedComponent.id, selectedComponent.size.height)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleWidthCommit(selectedComponent.id, selectedComponent.size.height);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editable height */}
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-height">H</label>
|
||||
<input
|
||||
id="prop-height"
|
||||
className={styles.infoInput}
|
||||
type="number"
|
||||
min={1}
|
||||
value={heightDraft}
|
||||
onChange={(e) => setHeightDraft(e.target.value)}
|
||||
onBlur={() => handleHeightCommit(selectedComponent.id, selectedComponent.size.width)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleHeightCommit(selectedComponent.id, selectedComponent.size.width);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visible checkbox */}
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-visible">Visible</label>
|
||||
<input
|
||||
id="prop-visible"
|
||||
type="checkbox"
|
||||
checked={selectedComponent.properties.visible !== false}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, 'visible', e.target.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disabled checkbox */}
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-disabled">Disabled</label>
|
||||
<input
|
||||
id="prop-disabled"
|
||||
type="checkbox"
|
||||
checked={selectedComponent.properties.disabled === true}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, 'disabled', e.target.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{selectedComponent.properties.label !== undefined && (
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-label">Label</label>
|
||||
<input
|
||||
id="prop-label"
|
||||
className={styles.infoInput}
|
||||
type="text"
|
||||
value={String(selectedComponent.properties.label ?? '')}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, 'label', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedComponent.properties.placeholder !== undefined && (
|
||||
<div className={styles.infoRow}>
|
||||
<label className={styles.infoKey} htmlFor="prop-placeholder">Placeholder</label>
|
||||
<input
|
||||
id="prop-placeholder"
|
||||
className={styles.infoInput}
|
||||
type="text"
|
||||
value={String(selectedComponent.properties.placeholder ?? '')}
|
||||
onChange={(e) =>
|
||||
updateComponentProperty(selectedComponent.id, 'placeholder', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedComponent.type === 'Dropdown' && (
|
||||
<DropdownOptionsEditor
|
||||
componentId={selectedComponent.id}
|
||||
options={
|
||||
Array.isArray(selectedComponent.properties.options)
|
||||
? (selectedComponent.properties.options as DropdownOption[])
|
||||
: []
|
||||
}
|
||||
configuredValue={
|
||||
typeof selectedComponent.properties.value === 'string'
|
||||
? selectedComponent.properties.value
|
||||
: ''
|
||||
}
|
||||
onOptionsChange={(opts) =>
|
||||
updateComponentProperty(selectedComponent.id, 'options', opts)
|
||||
}
|
||||
onConfiguredValueChange={(val) =>
|
||||
updateComponentProperty(selectedComponent.id, 'value', val)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedComponent.type === 'Table' && (
|
||||
<>
|
||||
<TableColumnsEditor
|
||||
columns={
|
||||
Array.isArray(selectedComponent.properties.columns)
|
||||
? (selectedComponent.properties.columns as TableColumn[])
|
||||
: []
|
||||
}
|
||||
onColumnsChange={(cols) =>
|
||||
updateComponentProperty(selectedComponent.id, 'columns', cols)
|
||||
}
|
||||
/>
|
||||
<TableRowsEditor
|
||||
rows={
|
||||
Array.isArray(selectedComponent.properties.rows)
|
||||
? (selectedComponent.properties.rows as TableRow[])
|
||||
: []
|
||||
}
|
||||
onRowsChange={(rowData) =>
|
||||
updateComponentProperty(selectedComponent.id, 'rows', rowData)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.infoEmpty}>No component selected</p>
|
||||
)}
|
||||
|
||||
{/* Live project JSON */}
|
||||
<div className={styles.jsonSection}>
|
||||
<div className={styles.infoHeader}>Project JSON</div>
|
||||
<pre className={styles.jsonPre}>{JSON.stringify(doc, null, 2)}</pre>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualEditor;
|
||||
53
frontend/src/components/WelcomePanel.module.css
Normal file
53
frontend/src/components/WelcomePanel.module.css
Normal file
@ -0,0 +1,53 @@
|
||||
.panel {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
margin-bottom: 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
font-size: 13px;
|
||||
color: #57606a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 28px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
font-size: 13px;
|
||||
color: #15803d;
|
||||
}
|
||||
48
frontend/src/components/WelcomePanel.tsx
Normal file
48
frontend/src/components/WelcomePanel.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import styles from './WelcomePanel.module.css';
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
title: 'Projects',
|
||||
body: 'Create and manage your UI projects.',
|
||||
},
|
||||
{
|
||||
title: 'Visual Editor',
|
||||
body: 'Drag and drop components onto the canvas.',
|
||||
},
|
||||
{
|
||||
title: 'JSON Editor',
|
||||
body: 'Edit the project definition directly.',
|
||||
},
|
||||
{
|
||||
title: 'Preview',
|
||||
body: 'Run the application as an end user.',
|
||||
},
|
||||
];
|
||||
|
||||
function WelcomePanel(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h1 className={styles.title}>Welcome to Conductor</h1>
|
||||
<p className={styles.subtitle}>
|
||||
A drag-and-drop UI builder for REST API integrations.
|
||||
Select an item from the sidebar to get started.
|
||||
</p>
|
||||
|
||||
<div className={styles.cardGrid}>
|
||||
{CARDS.map((card) => (
|
||||
<div key={card.title} className={styles.card}>
|
||||
<div className={styles.cardTitle}>{card.title}</div>
|
||||
<div className={styles.cardBody}>{card.body}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.status}>
|
||||
✓ Backend scaffold ready — Step 2 complete
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WelcomePanel;
|
||||
288
frontend/src/context/ProjectContext.tsx
Normal file
288
frontend/src/context/ProjectContext.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ProjectDocument, ApiProjectRow, Page } from '../types/project';
|
||||
import type { CanvasActions } from '../store/useProjectStore';
|
||||
import { useCanvasActions } from '../store/useProjectStore';
|
||||
import * as api from '../api/projectsApi';
|
||||
|
||||
// ── Default document used when creating a new project ────────────────────────
|
||||
|
||||
function makeNewDoc(name: string): ProjectDocument {
|
||||
return {
|
||||
schemaVersion: '0.1.0',
|
||||
project: {
|
||||
id: `proj_${Date.now()}`,
|
||||
name,
|
||||
pages: [{ id: 'page_main', name: 'Main Page', order: 0, components: [] }],
|
||||
actions: [],
|
||||
bindings: [],
|
||||
variables: {},
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Toast message ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToastKind = 'success' | 'error' | 'info';
|
||||
|
||||
export type Toast = {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// ── Context value type ────────────────────────────────────────────────────────
|
||||
|
||||
export type ProjectContextValue = {
|
||||
// Current document (canonical project JSON in memory)
|
||||
doc: ProjectDocument;
|
||||
setDoc: React.Dispatch<React.SetStateAction<ProjectDocument>>;
|
||||
|
||||
// Backend record metadata (null when no project is loaded from backend)
|
||||
projectRowId: number | null;
|
||||
projectName: string;
|
||||
setProjectName: (name: string) => void;
|
||||
|
||||
// Active page (first page, MVP)
|
||||
activePage: Page;
|
||||
|
||||
// Canvas mutation actions (add/move/remove components)
|
||||
canvas: CanvasActions;
|
||||
|
||||
// Canvas component selection
|
||||
selectedId: string | null;
|
||||
selectComponent: (id: string | null) => void;
|
||||
|
||||
// Persistence state
|
||||
isDirty: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
|
||||
// Project list (for the load picker)
|
||||
projectList: ApiProjectRow[];
|
||||
|
||||
// Operations
|
||||
newProject: (name?: string) => void;
|
||||
saveProject: () => Promise<void>;
|
||||
loadProject: (id: number) => Promise<void>;
|
||||
refreshProjectList: () => Promise<void>;
|
||||
|
||||
// Toast notifications
|
||||
toasts: Toast[];
|
||||
dismissToast: (id: number) => void;
|
||||
};
|
||||
|
||||
// ── Context creation ──────────────────────────────────────────────────────────
|
||||
|
||||
const ProjectContext = createContext<ProjectContextValue | null>(null);
|
||||
|
||||
// ── Provider ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProjectProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
// ── Document state ────────────────────────────────────────────────────────
|
||||
const [doc, setDoc] = useState<ProjectDocument>(() => makeNewDoc('New Project'));
|
||||
const [projectRowId, setProjectRowId] = useState<number | null>(null);
|
||||
const [projectName, setProjectNameState] = useState('New Project');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// ── Canvas mutations (delegate to useCanvasActions) ───────────────────────
|
||||
// Wrap setDoc so we can track dirty state on every canvas mutation
|
||||
const setDocAndMarkDirty: React.Dispatch<React.SetStateAction<ProjectDocument>> =
|
||||
useCallback(
|
||||
(action) => {
|
||||
setDoc(action);
|
||||
setIsDirty(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const canvas = useCanvasActions(doc, setDocAndMarkDirty);
|
||||
|
||||
// ── Component selection ───────────────────────────────────────────────────
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectComponent = useCallback((id: string | null) => {
|
||||
setSelectedId(id);
|
||||
}, []);
|
||||
|
||||
// ── Persistence state ─────────────────────────────────────────────────────
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// ── Project list ──────────────────────────────────────────────────────────
|
||||
const [projectList, setProjectList] = useState<ApiProjectRow[]>([]);
|
||||
|
||||
// ── Toast notifications ───────────────────────────────────────────────────
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
let _toastId = 0;
|
||||
|
||||
const addToast = useCallback((kind: ToastKind, message: string) => {
|
||||
const id = ++_toastId;
|
||||
setToasts((prev) => [...prev, { id, kind, message }]);
|
||||
// Auto-dismiss after 4 s
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
// ── setProjectName ────────────────────────────────────────────────────────
|
||||
const setProjectName = useCallback((name: string) => {
|
||||
setProjectNameState(name);
|
||||
setDoc((prev) => ({
|
||||
...prev,
|
||||
project: { ...prev.project, name },
|
||||
}));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
// ── Operations ────────────────────────────────────────────────────────────
|
||||
|
||||
const newProject = useCallback((name = 'New Project') => {
|
||||
const freshDoc = makeNewDoc(name);
|
||||
setDoc(freshDoc);
|
||||
setProjectRowId(null);
|
||||
setProjectNameState(name);
|
||||
setSelectedId(null);
|
||||
setIsDirty(false);
|
||||
}, []);
|
||||
|
||||
const saveProject = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Ensure the in-memory doc reflects the current project name
|
||||
const docToSave: ProjectDocument = {
|
||||
...doc,
|
||||
project: { ...doc.project, name: projectName },
|
||||
};
|
||||
|
||||
let row: ApiProjectRow;
|
||||
if (projectRowId === null) {
|
||||
// First save — create a new record
|
||||
row = await api.createProject(projectName, doc.project.description ?? '', docToSave);
|
||||
setProjectRowId(row.id);
|
||||
} else {
|
||||
// Subsequent save — update existing record
|
||||
row = await api.saveProject(projectRowId, projectName, doc.project.description ?? '', docToSave);
|
||||
}
|
||||
|
||||
// Sync doc.project.id to the backend row id (string form in the JSON)
|
||||
setDoc((prev) => ({
|
||||
...prev,
|
||||
project: { ...prev.project, id: String(row.id) },
|
||||
}));
|
||||
setIsDirty(false);
|
||||
addToast('success', `Project "${projectName}" saved.`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
addToast('error', `Save failed: ${message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [doc, projectRowId, projectName, addToast]);
|
||||
|
||||
const loadProject = useCallback(async (id: number) => {
|
||||
setIsLoading(true);
|
||||
setSelectedId(null);
|
||||
try {
|
||||
const row = await api.getProject(id);
|
||||
let loadedDoc: ProjectDocument;
|
||||
try {
|
||||
loadedDoc = JSON.parse(row.project_json) as ProjectDocument;
|
||||
} catch {
|
||||
throw new Error('Project data is not valid JSON.');
|
||||
}
|
||||
setDoc(loadedDoc);
|
||||
setProjectRowId(row.id);
|
||||
setProjectNameState(row.name);
|
||||
setIsDirty(false);
|
||||
addToast('success', `Loaded "${row.name}".`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
addToast('error', `Load failed: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
const refreshProjectList = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.listProjects();
|
||||
setProjectList(list);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
addToast('error', `Could not fetch project list: ${message}`);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// ── Context value (memoised to avoid unnecessary re-renders) ─────────────
|
||||
const value = useMemo<ProjectContextValue>(
|
||||
() => ({
|
||||
doc,
|
||||
setDoc: setDocAndMarkDirty,
|
||||
projectRowId,
|
||||
projectName,
|
||||
setProjectName,
|
||||
activePage: canvas.activePage,
|
||||
canvas,
|
||||
selectedId,
|
||||
selectComponent,
|
||||
isDirty,
|
||||
isLoading,
|
||||
isSaving,
|
||||
projectList,
|
||||
newProject,
|
||||
saveProject,
|
||||
loadProject,
|
||||
refreshProjectList,
|
||||
toasts,
|
||||
dismissToast,
|
||||
}),
|
||||
[
|
||||
doc,
|
||||
setDocAndMarkDirty,
|
||||
projectRowId,
|
||||
projectName,
|
||||
setProjectName,
|
||||
canvas,
|
||||
selectedId,
|
||||
selectComponent,
|
||||
isDirty,
|
||||
isLoading,
|
||||
isSaving,
|
||||
projectList,
|
||||
newProject,
|
||||
saveProject,
|
||||
loadProject,
|
||||
refreshProjectList,
|
||||
toasts,
|
||||
dismissToast,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Consumer hook ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProject(): ProjectContextValue {
|
||||
const ctx = useContext(ProjectContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useProject must be used inside <ProjectProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
5
frontend/src/declarations.d.ts
vendored
Normal file
5
frontend/src/declarations.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
// Allows TypeScript to resolve CSS Module imports as typed objects
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
22
frontend/src/index.css
Normal file
22
frontend/src/index.css
Normal file
@ -0,0 +1,22 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #1f2328;
|
||||
background: #ffffff;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
14
frontend/src/index.tsx
Normal file
14
frontend/src/index.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
29
frontend/src/setupProxy.js
Normal file
29
frontend/src/setupProxy.js
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* CRA dev-server proxy configuration.
|
||||
*
|
||||
* When running under Docker Compose the REACT_APP_API_URL environment variable
|
||||
* is set to http://backend:4000 so that the frontend container routes API
|
||||
* requests to the backend service by its Compose service name rather than
|
||||
* localhost (which, inside a container, resolves to the container itself).
|
||||
*
|
||||
* For local non-Docker development the variable is unset and the fallback
|
||||
* http://localhost:4000 is used, preserving the original behaviour.
|
||||
*
|
||||
* This file is loaded by react-scripts at dev-server startup and runs in the
|
||||
* Node process, so process.env is available. The plain "proxy" field in
|
||||
* package.json cannot read env-vars and has therefore been removed to avoid
|
||||
* conflicts.
|
||||
*/
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
const BACKEND_URL = process.env.REACT_APP_API_URL || 'http://localhost:4000';
|
||||
|
||||
module.exports = function (app) {
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: BACKEND_URL,
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
188
frontend/src/store/useProjectStore.ts
Normal file
188
frontend/src/store/useProjectStore.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { useCallback } from 'react';
|
||||
import type {
|
||||
ProjectDocument,
|
||||
CanvasComponent,
|
||||
ComponentType,
|
||||
Page,
|
||||
} from '../types/project';
|
||||
|
||||
// ── Default sizes per component type ─────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_SIZES: Record<ComponentType, { width: number; height: number }> = {
|
||||
Label: { width: 200, height: 32 },
|
||||
Button: { width: 120, height: 40 },
|
||||
TextInput: { width: 240, height: 40 },
|
||||
JsonViewer: { width: 400, height: 200 },
|
||||
Dropdown: { width: 260, height: 72 },
|
||||
Table: { width: 520, height: 260 },
|
||||
};
|
||||
|
||||
// ── Default label text per component type ─────────────────────────────────────
|
||||
|
||||
const DEFAULT_LABELS: Record<ComponentType, string> = {
|
||||
Label: 'Label',
|
||||
Button: 'Button',
|
||||
TextInput: '',
|
||||
JsonViewer: '',
|
||||
Dropdown: 'Dropdown',
|
||||
Table: 'Table',
|
||||
};
|
||||
|
||||
// ── ID counter (session-stable, not persistent) ───────────────────────────────
|
||||
|
||||
let _counter = 1;
|
||||
export function nextComponentId(type: ComponentType): string {
|
||||
return `${type.toLowerCase()}_${_counter++}`;
|
||||
}
|
||||
|
||||
// ── Grid snap helper ──────────────────────────────────────────────────────────
|
||||
|
||||
const GRID = 8;
|
||||
export function snap(v: number): number {
|
||||
return Math.round(v / GRID) * GRID;
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
// This hook owns canvas mutation logic only.
|
||||
// The doc state is owned by ProjectContext and passed in via doc/setDoc.
|
||||
|
||||
export type CanvasActions = {
|
||||
activePage: Page;
|
||||
addComponent: (type: ComponentType, x: number, y: number) => void;
|
||||
moveComponent: (id: string, dx: number, dy: number) => void;
|
||||
removeComponent: (id: string) => void;
|
||||
updateComponentProperty: (id: string, key: string, value: unknown) => void;
|
||||
updateComponentName: (id: string, name: string) => void;
|
||||
updateComponentSize: (id: string, width: number, height: number) => void;
|
||||
};
|
||||
|
||||
export function useCanvasActions(
|
||||
doc: ProjectDocument,
|
||||
setDoc: React.Dispatch<React.SetStateAction<ProjectDocument>>,
|
||||
): CanvasActions {
|
||||
// Always operate on the first page for this MVP step
|
||||
const activePage = doc.project.pages[0];
|
||||
|
||||
const updatePage = useCallback(
|
||||
(updater: (p: Page) => Page) => {
|
||||
setDoc((prev) => ({
|
||||
...prev,
|
||||
project: {
|
||||
...prev.project,
|
||||
pages: prev.project.pages.map((p, i) => (i === 0 ? updater(p) : p)),
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setDoc],
|
||||
);
|
||||
|
||||
const addComponent = useCallback(
|
||||
(type: ComponentType, x: number, y: number) => {
|
||||
const id = nextComponentId(type);
|
||||
const size = DEFAULT_SIZES[type];
|
||||
const label = DEFAULT_LABELS[type];
|
||||
|
||||
const properties: CanvasComponent['properties'] = { label, visible: true, disabled: false };
|
||||
if (type === 'TextInput') properties.placeholder = 'Enter text…';
|
||||
if (type === 'Dropdown') {
|
||||
properties.placeholder = 'Select an option';
|
||||
properties.options = [];
|
||||
properties.value = '';
|
||||
}
|
||||
if (type === 'Table') {
|
||||
properties.columns = [];
|
||||
properties.rows = [];
|
||||
}
|
||||
|
||||
const component: CanvasComponent = {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
position: { x: snap(x), y: snap(y) },
|
||||
size,
|
||||
properties,
|
||||
};
|
||||
|
||||
updatePage((p) => ({ ...p, components: [...p.components, component] }));
|
||||
},
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
const moveComponent = useCallback(
|
||||
(id: string, dx: number, dy: number) => {
|
||||
updatePage((p) => ({
|
||||
...p,
|
||||
components: p.components.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
position: {
|
||||
x: snap(Math.max(0, c.position.x + dx)),
|
||||
y: snap(Math.max(0, c.position.y + dy)),
|
||||
},
|
||||
}
|
||||
: c,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
const removeComponent = useCallback(
|
||||
(id: string) => {
|
||||
updatePage((p) => ({
|
||||
...p,
|
||||
components: p.components.filter((c) => c.id !== id),
|
||||
}));
|
||||
},
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
const updateComponentProperty = useCallback(
|
||||
(id: string, key: string, value: unknown) => {
|
||||
updatePage((p) => ({
|
||||
...p,
|
||||
components: p.components.map((c) =>
|
||||
c.id === id
|
||||
? { ...c, properties: { ...c.properties, [key]: value } }
|
||||
: c,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
const updateComponentName = useCallback(
|
||||
(id: string, name: string) => {
|
||||
updatePage((p) => ({
|
||||
...p,
|
||||
components: p.components.map((c) =>
|
||||
c.id === id ? { ...c, name } : c,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
const updateComponentSize = useCallback(
|
||||
(id: string, width: number, height: number) => {
|
||||
updatePage((p) => ({
|
||||
...p,
|
||||
components: p.components.map((c) =>
|
||||
c.id === id ? { ...c, size: { width, height } } : c,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updatePage],
|
||||
);
|
||||
|
||||
return {
|
||||
activePage,
|
||||
addComponent,
|
||||
moveComponent,
|
||||
removeComponent,
|
||||
updateComponentProperty,
|
||||
updateComponentName,
|
||||
updateComponentSize,
|
||||
};
|
||||
}
|
||||
308
frontend/src/types/project.ts
Normal file
308
frontend/src/types/project.ts
Normal file
@ -0,0 +1,308 @@
|
||||
// Types for the canonical Conductor project definition document.
|
||||
// These mirror the JSON schema at shared/schemas/conductor-project.schema.json.
|
||||
|
||||
// ── Variable types ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The runtime type declared for a project variable.
|
||||
* Mirrors the `type` enum in the Variable $def of conductor-project.schema.json.
|
||||
*/
|
||||
export type VariableType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
| 'array';
|
||||
|
||||
/**
|
||||
* A named global variable declaration in a Conductor project.
|
||||
* Mirrors the `Variable` $def in conductor-project.schema.json.
|
||||
*
|
||||
* `defaultValue` is the design-time (configured) starting value.
|
||||
* The runtime initializes its own ephemeral copy from this field.
|
||||
* The canonical project document is never mutated at runtime.
|
||||
*/
|
||||
export interface Variable {
|
||||
/** The declared runtime type of this variable. */
|
||||
type: VariableType;
|
||||
/**
|
||||
* Design-time default value. Intended to be compatible with `type`;
|
||||
* compatibility is not enforced in Step 18.1.
|
||||
* Absence of this property means the runtime variable begins as `undefined`.
|
||||
* An explicit `null` value is preserved as `null`.
|
||||
*/
|
||||
defaultValue?: unknown;
|
||||
/** Optional documentation string shown in tooling. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Binding types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A project-level binding that connects a data source expression to a target
|
||||
* expression, optionally activated by a trigger event.
|
||||
*
|
||||
* Mirrors the `Binding` $def in conductor-project.schema.json.
|
||||
*
|
||||
* Source / target expression conventions:
|
||||
* "components.<name>.<property>" — component property
|
||||
* "actions.<actionId>.response" — action response (used in Step 15)
|
||||
* "variables.<variableName>" — global variable
|
||||
*
|
||||
* For Step 15 button-click bindings:
|
||||
* source: "actions.<actionId>.response"
|
||||
* target: "components.<viewerName>.value"
|
||||
* trigger: "onClick"
|
||||
*
|
||||
* The button component that fires the action is declared in the component's own
|
||||
* events[] array (ComponentEvent), not in this binding.
|
||||
*/
|
||||
export type Binding = {
|
||||
/** Unique identifier for this binding within the project. */
|
||||
id: string;
|
||||
/**
|
||||
* Source expression.
|
||||
* e.g. "actions.action_httpbin.response" or "components.myInput.value"
|
||||
*/
|
||||
source: string;
|
||||
/**
|
||||
* Target expression.
|
||||
* e.g. "components.resultsViewer.value" or "variables.lastStatus"
|
||||
*/
|
||||
target: string;
|
||||
/**
|
||||
* Event name that activates this binding (e.g. "onClick", "onChange", "onLoad").
|
||||
* Defaults to "onChange" if omitted.
|
||||
*/
|
||||
trigger?: string;
|
||||
/**
|
||||
* Optional inline JavaScript expression applied to the source value before
|
||||
* writing to the target. The source value is available as `value`.
|
||||
*/
|
||||
transform?: string;
|
||||
};
|
||||
|
||||
// ── Component event types ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A component-level event handler that fires an action when the named event
|
||||
* occurs on the component.
|
||||
*
|
||||
* Mirrors the `Event` $def in conductor-project.schema.json.
|
||||
*
|
||||
* For Step 15 button-to-REST binding:
|
||||
* event: "onClick"
|
||||
* actionId: "<actionId>"
|
||||
*/
|
||||
export type ComponentEvent = {
|
||||
/** Name of the triggering event (e.g. "onClick", "onChange", "onLoad"). */
|
||||
event: string;
|
||||
/** ID of the project-level action to execute when this event fires. */
|
||||
actionId: string;
|
||||
/**
|
||||
* Maps action input parameter names to runtime value expressions.
|
||||
* Not used in Step 15 (anonymous execution only).
|
||||
*/
|
||||
inputMap?: Record<string, string>;
|
||||
};
|
||||
|
||||
// ── Component types supported by the Visual Editor (active set) ───────────────
|
||||
|
||||
export type ComponentType =
|
||||
| 'Label'
|
||||
| 'Button'
|
||||
| 'TextInput'
|
||||
| 'JsonViewer'
|
||||
| 'Dropdown'
|
||||
| 'Table';
|
||||
|
||||
// Schema-only component types not yet active in the runtime
|
||||
// (retained for future steps; not used by Visual Editor or Preview)
|
||||
export type FullComponentType =
|
||||
| ComponentType
|
||||
| 'TextArea'
|
||||
| 'Checkbox'
|
||||
| 'RadioGroup'
|
||||
| 'StatusPanel'
|
||||
| 'Container';
|
||||
|
||||
// ── Dropdown option ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single option in a Dropdown component's static option list.
|
||||
* Both fields are strings — values are compared by string equality.
|
||||
*/
|
||||
export type DropdownOption = {
|
||||
/** Display text shown in the dropdown. */
|
||||
label: string;
|
||||
/** The value stored in runtime state when this option is selected. */
|
||||
value: string;
|
||||
};
|
||||
|
||||
// ── Table types ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single column definition for a Table component.
|
||||
* Matches the canonical schema shape: key + header + optional width.
|
||||
* key: the property name to read from each row object.
|
||||
* header: the column header text displayed in the table.
|
||||
* width: optional fixed column width in pixels (not yet used by renderers).
|
||||
*/
|
||||
export type TableColumn = {
|
||||
key: string;
|
||||
header: string;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single row in a Table component's static row list.
|
||||
* Values may be any JSON-serialisable type.
|
||||
*/
|
||||
export type TableRow = Record<string, unknown>;
|
||||
|
||||
// ── REST Action types ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Legacy action-local response mapping rule.
|
||||
*
|
||||
* @deprecated Use top-level `project.bindings` with Conductor runtime
|
||||
* dot paths for response data movement.
|
||||
*/
|
||||
export type ResponseMappingRule = {
|
||||
/**
|
||||
* Legacy source path retained for backward compatibility.
|
||||
* This rule is not executed by Preview or the backend proxy.
|
||||
*/
|
||||
source: string;
|
||||
|
||||
/**
|
||||
* Legacy target path retained for backward compatibility.
|
||||
* This rule is not executed by Preview or the backend proxy.
|
||||
*/
|
||||
target: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authentication strategy for a REST action.
|
||||
* Determines how the backend injects credentials at execution time.
|
||||
* Credentials themselves are never stored in the project definition.
|
||||
*/
|
||||
export type AuthenticationType =
|
||||
| 'anonymous'
|
||||
| 'bearerToken'
|
||||
| 'basicAuth'
|
||||
| 'apiKeyHeader'
|
||||
| 'apiKeyQueryParameter';
|
||||
|
||||
/** HTTP methods permitted for a REST action. */
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
/**
|
||||
* A REST API action definition.
|
||||
* Describes how to call an external HTTP endpoint.
|
||||
* Mirrors the `Action` $def in conductor-project.schema.json.
|
||||
*/
|
||||
export type RestAction = {
|
||||
/** Unique identifier for this action within the project. */
|
||||
id: string;
|
||||
/** Human-readable action name shown in the actions panel. */
|
||||
name: string;
|
||||
/** Optional description of what this action does. */
|
||||
description?: string;
|
||||
/** HTTP method. */
|
||||
method: HttpMethod;
|
||||
/**
|
||||
* Target URL template. Path parameter placeholders use {{paramName}} syntax,
|
||||
* e.g. https://api.example.com/items/{{itemId}}.
|
||||
*/
|
||||
url: string;
|
||||
/** Static HTTP headers. Values may use {{variableName}} template syntax. */
|
||||
headers?: Record<string, string>;
|
||||
/** Static URL query parameters. Values may use {{variableName}} template syntax. */
|
||||
queryParameters?: Record<string, string>;
|
||||
/**
|
||||
* Named path segment substitutions. Keys match {{paramName}} placeholders in
|
||||
* the URL. Values may use {{variableName}} template syntax.
|
||||
*/
|
||||
pathParameters?: Record<string, string>;
|
||||
/**
|
||||
* Request body template string. Typically JSON with {{variableName}}
|
||||
* placeholders resolved at runtime. Ignored for GET and DELETE.
|
||||
*/
|
||||
bodyTemplate?: string;
|
||||
/** Authentication strategy. Credentials are resolved by the backend at runtime. */
|
||||
authenticationType: AuthenticationType;
|
||||
/**
|
||||
* @deprecated Use `project.bindings` for response data movement.
|
||||
* This field is retained for backward compatibility but is not executed
|
||||
* by the Preview runtime or backend proxy. See docs/response-mapping-model.md.
|
||||
*/
|
||||
responseMapping?: ResponseMappingRule[];
|
||||
};
|
||||
|
||||
// ── Sub-types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Position = { x: number; y: number };
|
||||
|
||||
export type Size = { width: number; height: number };
|
||||
|
||||
export type ComponentProperties = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
visible?: boolean;
|
||||
disabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CanvasComponent = {
|
||||
id: string;
|
||||
type: ComponentType;
|
||||
name: string;
|
||||
position: Position;
|
||||
size: Size;
|
||||
properties: ComponentProperties;
|
||||
/** Component-level event handlers (e.g. onClick → actionId). */
|
||||
events?: ComponentEvent[];
|
||||
};
|
||||
|
||||
export type Page = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
components: CanvasComponent[];
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
pages: Page[];
|
||||
actions: RestAction[];
|
||||
/**
|
||||
* Project-level bindings — connect action responses or component properties
|
||||
* to target component properties or variables.
|
||||
* Mirrors the schema's Binding $def.
|
||||
*/
|
||||
bindings: Binding[];
|
||||
variables: Record<string, Variable>;
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProjectDocument = {
|
||||
schemaVersion: string;
|
||||
project: Project;
|
||||
};
|
||||
|
||||
// ── Backend API response types ────────────────────────────────────────────────
|
||||
// Shape returned by GET /api/projects and GET /api/projects/:id
|
||||
|
||||
export type ApiProjectRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
project_json: string; // serialised ProjectDocument
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
308
package-lock.json
generated
Normal file
308
package-lock.json
generated
Normal file
@ -0,0 +1,308 @@
|
||||
{
|
||||
"name": "Conductor",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"ajv-cli": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-cli": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-cli/-/ajv-cli-5.0.0.tgz",
|
||||
"integrity": "sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0",
|
||||
"fast-json-patch": "^2.0.0",
|
||||
"glob": "^7.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"json-schema-migrate": "^2.0.0",
|
||||
"json5": "^2.1.3",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ajv": "dist/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ts-node": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ts-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-patch": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-2.2.1.tgz",
|
||||
"integrity": "sha512-4j5uBaTnsYAV5ebkidvxiLUYOwjQ+JSFljeqfTxCrH9bDmlCQaOJFS84oDJ2rAXZq2yskmk3ORfoP9DCwqFNig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-patch/node_modules/fast-deep-equal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
|
||||
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-migrate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
|
||||
"integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user