Add multi-page application authoring

This commit is contained in:
Victor Wiebe 2026-08-07 15:20:30 -04:00
parent a3372b33c2
commit 60273d7528
22 changed files with 284 additions and 71 deletions

View File

@ -83,7 +83,7 @@ Approved on 2026-08-07 for Slice 7a:
## Release Boundary ## Release Boundary
The MVP is complete only when all required component, configuration, authentication, security, validation, persistence, local-user/RBAC, publishing, first-run administration, multi-page/scoped-variable, and deployment tasks in `ROADMAP.md` are complete; all six workflows above pass the Slice 6 release-validation process; and Slice 7a, Slice 7b, and Slice 7c acceptance pass. The MVP is complete only when all required component, configuration, authentication, security, validation, persistence, local-user/RBAC, publishing, first-run administration, multi-page/scoped-variable, Visual Editor command-ribbon polish, and deployment tasks in `ROADMAP.md` are complete; all six workflows above pass the Slice 6 release-validation process; and Slice 7a, Slice 7b, Slice 7c, and Slice 7d acceptance pass.
Post-MVP scope includes AI assistance, OIDC/SSO beyond the local authentication architecture, OAuth 2.0 for REST actions, IBM Cloud IAM, mTLS, advanced orchestration, and the future capabilities listed in `ROADMAP.md`. Post-MVP scope includes AI assistance, OIDC/SSO beyond the local authentication architecture, OAuth 2.0 for REST actions, IBM Cloud IAM, mTLS, advanced orchestration, and the future capabilities listed in `ROADMAP.md`.

View File

@ -26,8 +26,9 @@ The slice files remain the detailed implementation plans. When an older list con
| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Complete | None | | [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Complete | None |
| [Slice 7](SLICE7.md) | MVP scope decision and requirement governance | Complete | None | | [Slice 7](SLICE7.md) | MVP scope decision and requirement governance | Complete | None |
| [Slice 7a](SLICE7a.md) | Local authentication, RBAC, and application publishing | Complete | Implementation, security validation, manual acceptance, cleanup, and explicit sign-off passed | | [Slice 7a](SLICE7a.md) | Local authentication, RBAC, and application publishing | Complete | Implementation, security validation, manual acceptance, cleanup, and explicit sign-off passed |
| [Slice 7b](SLICE7b.md) | Browser-based first-run administrator setup | Planned | Fresh-install setup, takeover prevention, recovery validation, and explicit sign-off | | [Slice 7b](SLICE7b.md) | Browser-based first-run administrator setup | Complete | Fresh-install setup, takeover prevention, recovery validation, manual acceptance, and explicit sign-off passed |
| [Slice 7c](SLICE7c.md) | Multi-page applications and variable scope | Planned | Page authoring, scoped runtime, deep links, security validation, and explicit sign-off | | [Slice 7c](SLICE7c.md) | Multi-page applications and variable scope | Complete | Page authoring, scoped runtime, deep links, security validation, and explicit sign-off |
| [Slice 7d](SLICE7d.md) | Visual Editor professional command ribbon | Planned | Ribbon implementation, responsive/accessibility validation, manual visual acceptance, and explicit sign-off |
| [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain | | [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain |
| [Slice 9](SLICE9.md) | OIDC and enterprise SSO | Post-MVP | Begins after Slice 7a; provider and provisioning decisions remain | | [Slice 9](SLICE9.md) | OIDC and enterprise SSO | Post-MVP | Begins after Slice 7a; provider and provisioning decisions remain |
@ -258,11 +259,11 @@ Controlled orchestration is not required in the v0.1.0 demonstration. Execution
### Slice 7b — First-run administrator setup ### Slice 7b — First-run administrator setup
- [ ] Replace the normal Docker-command bootstrap experience with a secure browser-based first-run setup screen. - [x] Replace the normal Docker-command bootstrap experience with a secure browser-based first-run setup screen.
- [ ] Make initial-admin creation atomic and available only while the installation contains no users. - [x] Make initial-admin creation atomic and available only while the installation contains no users.
- [ ] Create an authenticated session after successful setup without exposing passwords or session values. - [x] Create an authenticated session after successful setup without exposing passwords or session values.
- [ ] Preserve the CLI bootstrap only as a documented emergency/recovery path. - [x] Preserve the CLI bootstrap only as a documented emergency/recovery path.
- [ ] Complete fresh-install, concurrent-takeover, existing-installation, restart, and manual acceptance coverage. - [x] Complete fresh-install, concurrent-takeover, existing-installation, restart, and manual acceptance coverage.
### Slice 7c — Multi-page applications and variable scope ### Slice 7c — Multi-page applications and variable scope
@ -275,9 +276,16 @@ Controlled orchestration is not required in the v0.1.0 demonstration. Execution
- [ ] Reject cross-page component/page-variable references and crafted cross-page published inputs. - [ ] Reject cross-page component/page-variable references and crafted cross-page published inputs.
- [ ] Complete compatibility, editor, runtime, publishing, security, browser, and manual acceptance coverage. - [ ] Complete compatibility, editor, runtime, publishing, security, browser, and manual acceptance coverage.
### Slice 7d — Visual Editor command ribbon
- [ ] Replace dense native-looking Visual Editor command rows with a grouped professional ribbon.
- [ ] Distinguish project, page, page-settings, lifecycle, primary, disabled, and destructive controls.
- [ ] Preserve Slice 7c behavior while improving desktop responsiveness, keyboard access, focus, and visual hierarchy.
- [ ] Complete automated regression and accessibility checks plus manual visual acceptance and explicit sign-off.
## 8. Documentation, Packaging, and Release ## 8. Documentation, Packaging, and Release
Slice 8 release packaging now depends on completed Slice 7a authentication/RBAC/publishing, Slice 7b first-run setup, and Slice 7c multi-page/scoped-variable behavior and documentation. Slice 9 OIDC/SSO remains post-v0.1.0 unless the product owner explicitly changes the boundary. Slice 8 release packaging now depends on completed Slice 7a authentication/RBAC/publishing, Slice 7b first-run setup, Slice 7c multi-page/scoped-variable behavior, and Slice 7d Visual Editor ribbon polish and documentation. Slice 9 OIDC/SSO remains post-v0.1.0 unless the product owner explicitly changes the boundary.
### Slice 8 — Documentation ownership and reconciliation ### Slice 8 — Documentation ownership and reconciliation

View File

@ -84,6 +84,15 @@ function semanticIssues(doc: JsonObject): ValidationIssue[] {
add(issues, 'DUPLICATE_PAGE_ID', '/project/pages', `Page id "${id}" is duplicated.`); add(issues, 'DUPLICATE_PAGE_ID', '/project/pages', `Page id "${id}" is duplicated.`);
for (const name of duplicates(pages.map((page) => page.name))) for (const name of duplicates(pages.map((page) => page.name)))
add(issues, 'DUPLICATE_PAGE_NAME', '/project/pages', `Page name "${name}" is duplicated.`); add(issues, 'DUPLICATE_PAGE_NAME', '/project/pages', `Page name "${name}" is duplicated.`);
const effectiveSlug = (page: JsonObject) => typeof page.slug === 'string' && page.slug
? page.slug
: String(page.name ?? page.id).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
for (const slug of duplicates(pages.map(effectiveSlug)))
add(issues, 'DUPLICATE_PAGE_SLUG', '/project/pages', `Page slug "${slug}" is duplicated.`);
const pageIds = new Set(pages.map(page => page.id));
const defaultPageId = (project.settings as JsonObject | undefined)?.defaultPageId;
if (defaultPageId !== undefined && !pageIds.has(defaultPageId))
add(issues, 'DANGLING_DEFAULT_PAGE', '/project/settings/defaultPageId', `Default page "${defaultPageId}" does not exist.`);
for (const id of duplicates(actions.map((action) => action.id))) for (const id of duplicates(actions.map((action) => action.id)))
add(issues, 'DUPLICATE_ACTION_ID', '/project/actions', `Action id "${id}" is duplicated.`); add(issues, 'DUPLICATE_ACTION_ID', '/project/actions', `Action id "${id}" is duplicated.`);
for (const name of duplicates(actions.map((action) => action.name))) for (const name of duplicates(actions.map((action) => action.name)))
@ -104,22 +113,30 @@ function semanticIssues(doc: JsonObject): ValidationIssue[] {
} }
for (const id of duplicates(components.map(({ component }) => component.id))) for (const id of duplicates(components.map(({ component }) => component.id)))
add(issues, 'DUPLICATE_COMPONENT_ID', '/project/pages', `Component id "${id}" is duplicated across the project.`); add(issues, 'DUPLICATE_COMPONENT_ID', '/project/pages', `Component id "${id}" is duplicated across the project.`);
for (const [name, matches] of componentsByName) { pages.forEach((page, pageIndex) => {
if (matches.length > 1) for (const name of duplicates((page.components as JsonObject[]).map(component => component.name)))
add(issues, 'DUPLICATE_COMPONENT_NAME', '/project/pages', `Component name "${name}" is duplicated; runtime references require project-wide unique names.`); add(issues, 'DUPLICATE_COMPONENT_NAME', `/project/pages/${pageIndex}/components`, `Component name "${name}" is duplicated within this page.`);
} });
Object.entries(variables).forEach(([name, variable]) => {
const definition = variable as JsonObject;
if (definition.scope === 'page' && !pageIds.has(definition.pageId))
add(issues, 'DANGLING_VARIABLE_PAGE', `/project/variables/${name}/pageId`, `Variable page "${definition.pageId}" does not exist.`);
});
const checkEvents = (events: JsonObject[] | undefined, path: string, allowed: Set<string>) => { const checkEvents = (events: JsonObject[] | undefined, path: string, allowed: Set<string>) => {
(events ?? []).forEach((event, index) => { (events ?? []).forEach((event, index) => {
if (!actionIds.has(event.actionId)) if (event.actionId !== undefined && !actionIds.has(event.actionId))
add(issues, 'DANGLING_ACTION_REFERENCE', `${path}/${index}/actionId`, `Action "${event.actionId}" does not exist.`); add(issues, 'DANGLING_ACTION_REFERENCE', `${path}/${index}/actionId`, `Action "${event.actionId}" does not exist.`);
if (event.navigateToPageId !== undefined && !pageIds.has(event.navigateToPageId))
add(issues, 'DANGLING_PAGE_REFERENCE', `${path}/${index}/navigateToPageId`, `Page "${event.navigateToPageId}" does not exist.`);
if (!allowed.has(event.event)) if (!allowed.has(event.event))
add(issues, 'EVENT_UNSUPPORTED', `${path}/${index}/event`, `Event "${event.event}" is not supported here.`); add(issues, 'EVENT_UNSUPPORTED', `${path}/${index}/event`, `Event "${event.event}" is not supported here.`);
if (event.inputMap !== undefined) if (event.inputMap !== undefined)
add(issues, 'LEGACY_INPUT_MAP', `${path}/${index}/inputMap`, 'inputMap is retained for compatibility but is not executed; use action request templates.', 'warning'); add(issues, 'LEGACY_INPUT_MAP', `${path}/${index}/inputMap`, 'inputMap is retained for compatibility but is not executed; use action request templates.', 'warning');
}); });
}; };
pages.forEach((page, index) => checkEvents(page.events, `/project/pages/${index}/events`, new Set(['onLoad']))); pages.forEach((page, index) => checkEvents(page.events, `/project/pages/${index}/events`, new Set(['onLoad', 'onEnter'])));
components.forEach(({ component, pageIndex, componentIndex }) => { components.forEach(({ component, pageIndex, componentIndex }) => {
const componentPath = `/project/pages/${pageIndex}/components/${componentIndex}`; const componentPath = `/project/pages/${pageIndex}/components/${componentIndex}`;
checkEvents(component.events, `/project/pages/${pageIndex}/components/${componentIndex}/events`, new Set(['onClick'])); checkEvents(component.events, `/project/pages/${pageIndex}/components/${componentIndex}/events`, new Set(['onClick']));

View File

@ -6,7 +6,8 @@ import { ProxyPolicyError, sanitizeUrl } from '../lib/proxyPolicy';
import { recordExecution, type ExecutionOutcome } from '../db/executions'; import { recordExecution, type ExecutionOutcome } from '../db/executions';
const router=Router(); const router=Router();
type Snapshot={schemaVersion:string;project:{id:string;name:string;description?:string;pages:unknown[];actions:RestActionInput[];bindings:unknown[];variables:Record<string,{type:string;defaultValue?:unknown}>;settings?:Record<string,unknown>}}; type SnapshotPage={id:string;components:Array<{name:string}>};
type Snapshot={schemaVersion:string;project:{id:string;name:string;description?:string;pages:SnapshotPage[];actions:RestActionInput[];bindings:unknown[];variables:Record<string,{type:string;defaultValue?:unknown;scope?:'global'|'page';pageId?:string}>;settings?:Record<string,unknown>}};
const canAccess=(row:PublishedAppRow,req:Request)=>row.visibility==='public'||!!req.principal; const canAccess=(row:PublishedAppRow,req:Request)=>row.visibility==='public'||!!req.principal;
function publicDocument(snapshot:Snapshot):Snapshot { return {...snapshot,project:{...snapshot.project,actions:snapshot.project.actions.map((action)=>{const refs=referencedNames(action);return{id:action.id,name:action.name,description:action.description,method:action.method,url:'published://server-owned',headers:{},queryParameters:{},pathParameters:{},bodyTemplate:'',authenticationType:'anonymous',runtimeInputComponents:[...refs.components],runtimeInputVariables:[...refs.variables]};})}}; } function publicDocument(snapshot:Snapshot):Snapshot { return {...snapshot,project:{...snapshot.project,actions:snapshot.project.actions.map((action)=>{const refs=referencedNames(action);return{id:action.id,name:action.name,description:action.description,method:action.method,url:'published://server-owned',headers:{},queryParameters:{},pathParameters:{},bodyTemplate:'',authenticationType:'anonymous',runtimeInputComponents:[...refs.components],runtimeInputVariables:[...refs.variables]};})}}; }
function runtimeDto(row:PublishedAppRow){const snapshot=JSON.parse(row.snapshot_json) as Snapshot;return {slug:row.slug,displayName:row.display_name,description:row.description,visibility:row.visibility,version:row.version,document:publicDocument(snapshot)};} function runtimeDto(row:PublishedAppRow){const snapshot=JSON.parse(row.snapshot_json) as Snapshot;return {slug:row.slug,displayName:row.display_name,description:row.description,visibility:row.visibility,version:row.version,document:publicDocument(snapshot)};}

View File

@ -24,8 +24,9 @@ This matrix maps every release-critical requirement area in `docs/REQUIREMENTS.m
| R14 | Accessible, scannable Actions & Bindings authoring with progressive disclosure and preserved local drafts | Slice 6a | 25 frontend suites / 520 tests and `SLICE6a_MANUAL_TEST.md` acceptance | Accepted | | R14 | Accessible, scannable Actions & Bindings authoring with progressive disclosure and preserved local drafts | Slice 6a | 25 frontend suites / 520 tests and `SLICE6a_MANUAL_TEST.md` acceptance | Accepted |
| R15 | Local authentication, secure sessions, global admin/user RBAC, admin-only authoring, and user lifecycle management | Slice 7a | Backend integration security matrix, frontend regression suite, and accepted manual admin/user workflows | Accepted | | R15 | Local authentication, secure sessions, global admin/user RBAC, admin-only authoring, and user lifecycle management | Slice 7a | Backend integration security matrix, frontend regression suite, and accepted manual admin/user workflows | Accepted |
| R16 | Immutable standalone published applications with public/authenticated visibility and server-owned published action execution | Slice 7a | Server-snapshot integration coverage and accepted public/restricted publishing workflows | Accepted | | R16 | Immutable standalone published applications with public/authenticated visibility and server-owned published action execution | Slice 7a | Server-snapshot integration coverage and accepted public/restricted publishing workflows | Accepted |
| R17 | A fresh installation creates its initial administrator through a secure browser first-run flow without requiring Docker commands | Slice 7b | Atomic setup/security tests, fresh-install browser E2E, recovery verification, and manual acceptance | Planned | | R17 | A fresh installation creates its initial administrator through a secure browser first-run flow without requiring Docker commands | Slice 7b | Atomic setup/security integration tests, frontend setup/password tests, recovery verification, and accepted manual workflow | Accepted |
| R18 | Authored and published applications support multiple deep-linked pages with page-local components and explicit global/page runtime-variable scope | Slice 7c | Schema/editor/runtime/publishing tests, multi-page browser E2E, security validation, and manual acceptance | Planned | | R18 | Authored and published applications support multiple deep-linked pages with page-local components and explicit global/page runtime-variable scope | Slice 7c | Schema/editor/runtime/publishing tests, multi-page browser E2E, security validation, and manual acceptance | Accepted |
| R19 | The Visual Editor presents project, page, page-setting, and lifecycle commands in a professional, accessible, responsive command ribbon | Slice 7d | Ribbon component/regression/accessibility checks and manual visual acceptance | Planned |
## Approved acceptance workflows ## Approved acceptance workflows

View File

@ -53,8 +53,8 @@ function AppContent(): React.ReactElement {
// ProjectProvider wraps the entire app so all editor views share one context. // ProjectProvider wraps the entire app so all editor views share one context.
function AuthenticatedApp(): React.ReactElement { function AuthenticatedApp(): React.ReactElement {
const { user, loading, setupRequired } = useAuth(); const { user, loading, setupRequired } = useAuth();
const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)\/?$/); const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)(?:\/([^/]+))?\/?$/);
if (publishedMatch) return <ProjectProvider><PublishedApp slug={decodeURIComponent(publishedMatch[1])}/></ProjectProvider>; if (publishedMatch) return <ProjectProvider><PublishedApp slug={decodeURIComponent(publishedMatch[1])} pageSlug={publishedMatch[2] ? decodeURIComponent(publishedMatch[2]) : undefined}/></ProjectProvider>;
if (loading) return <p style={{padding:32}}>Loading</p>; if (loading) return <p style={{padding:32}}>Loading</p>;
if (setupRequired) return <FirstRunSetup />; if (setupRequired) return <FirstRunSetup />;
if (!user) return <Login />; if (!user) return <Login />;

View File

@ -6,4 +6,4 @@ export type PublishedRuntime=PublishedSummary&{document:ProjectDocument};
async function json<T>(response:Response):Promise<T>{if(!response.ok){const body=await response.json().catch(()=>({})) as {error?:string};throw new Error(body.error??`HTTP ${response.status}`);}return response.json() as Promise<T>;} async function json<T>(response:Response):Promise<T>{if(!response.ok){const body=await response.json().catch(()=>({})) as {error?:string};throw new Error(body.error??`HTTP ${response.status}`);}return response.json() as Promise<T>;}
export const listPublished=()=>apiFetch('/api/published-apps').then(json<PublishedSummary[]>); export const listPublished=()=>apiFetch('/api/published-apps').then(json<PublishedSummary[]>);
export const getPublished=(slug:string)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}`).then(json<PublishedRuntime>); export const getPublished=(slug:string)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}`).then(json<PublishedRuntime>);
export const executePublished=(slug:string,actionId:string,componentValues:Record<string,unknown>,variableValues:Record<string,unknown>)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}/actions/${encodeURIComponent(actionId)}/execute`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({componentValues,variableValues})}).then(json<ProxyResponse>); export const executePublished=(slug:string,actionId:string,componentValues:Record<string,unknown>,variableValues:Record<string,unknown>,pageId?:string)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}/actions/${encodeURIComponent(actionId)}/execute`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({componentValues,variableValues,pageId})}).then(json<ProxyResponse>);

View File

@ -128,12 +128,12 @@ function computeDiagnostics(
const triggeredActionIds = new Set<string>(); const triggeredActionIds = new Set<string>();
for (const page of pages) { for (const page of pages) {
for (const ev of page.events ?? []) { for (const ev of page.events ?? []) {
triggeredActionIds.add(ev.actionId); if (ev.actionId) triggeredActionIds.add(ev.actionId);
} }
} }
for (const comp of allComponents) { for (const comp of allComponents) {
for (const ev of comp.events ?? []) { for (const ev of comp.events ?? []) {
triggeredActionIds.add(ev.actionId); if (ev.actionId) triggeredActionIds.add(ev.actionId);
} }
} }
@ -143,6 +143,7 @@ function computeDiagnostics(
for (const comp of allComponents) { for (const comp of allComponents) {
for (const ev of comp.events ?? []) { for (const ev of comp.events ?? []) {
if (ev.event === 'onClick') { if (ev.event === 'onClick') {
if (!ev.actionId) continue;
const list = onClickTriggers.get(ev.actionId) ?? []; const list = onClickTriggers.get(ev.actionId) ?? [];
list.push(comp.name); list.push(comp.name);
onClickTriggers.set(ev.actionId, list); onClickTriggers.set(ev.actionId, list);
@ -406,7 +407,7 @@ function computeDiagnostics(
for (const comp of allComponents) { for (const comp of allComponents) {
for (const ev of comp.events ?? []) { for (const ev of comp.events ?? []) {
if (!actionIds.has(ev.actionId)) { if (ev.actionId && !actionIds.has(ev.actionId)) {
// Find bindings that reference this action (any source path form) // Find bindings that reference this action (any source path form)
const relatedBindings = bindings.filter((b) => { const relatedBindings = bindings.filter((b) => {
const parsed = parseActionSourcePath(b.source); const parsed = parseActionSourcePath(b.source);
@ -906,7 +907,7 @@ function BindingCard({
// ── Main component ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
function ActionInspector(): React.ReactElement { function ActionInspector(): React.ReactElement {
const { doc, setDoc } = useProject(); const { doc, setDoc, activePageId } = useProject();
const { actions, bindings, pages, variables } = doc.project; const { actions, bindings, pages, variables } = doc.project;
const allComponents = useMemo( const allComponents = useMemo(
@ -1206,7 +1207,7 @@ function ActionInspector(): React.ReactElement {
<div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{Object.keys(variables).length}</span><button type="button" className={styles.primaryButton} data-testid="add-variable" disabled={addingVariable || editingVariableName !== null} onClick={() => { setOpenSections((current) => new Set(current).add('variables')); setAddingVariable(true); }}>+ Add variable</button></div> <div className={styles.sectionHeaderMeta}><span className={styles.sectionCount}>{Object.keys(variables).length}</span><button type="button" className={styles.primaryButton} data-testid="add-variable" disabled={addingVariable || editingVariableName !== null} onClick={() => { setOpenSections((current) => new Set(current).add('variables')); setAddingVariable(true); }}>+ Add variable</button></div>
</div> </div>
<div className={styles.sectionBody} hidden={!openSections.has('variables')}> <div className={styles.sectionBody} hidden={!openSections.has('variables')}>
{addingVariable && <VariableEditor name="" variable={{ type: 'string' }} variables={variables} isNew onSave={(name, variable) => { setDoc((current) => setVariable(current, name, variable)); setAddingVariable(false); }} onCancel={() => setAddingVariable(false)} />} {addingVariable && <VariableEditor name="" variable={{ type: 'string' }} variables={variables} pages={pages} activePageId={activePageId} isNew onSave={(name, variable) => { setDoc((current) => setVariable(current, name, variable)); setAddingVariable(false); }} onCancel={() => setAddingVariable(false)} />}
{Object.keys(variables).length === 0 && !addingVariable ? <div className={styles.empty}>No variables declared.</div> : <div className={styles.recordList}>{Object.entries(variables).map(([name, variable]) => ( {Object.keys(variables).length === 0 && !addingVariable ? <div className={styles.empty}>No variables declared.</div> : <div className={styles.recordList}>{Object.entries(variables).map(([name, variable]) => (
<div className={styles.recordGroup} key={name}> <div className={styles.recordGroup} key={name}>
<div className={styles.variableCard}> <div className={styles.variableCard}>
@ -1220,7 +1221,7 @@ function ActionInspector(): React.ReactElement {
<div className={styles.actionControls}><button type="button" className={styles.smallButton} aria-label={`Edit variable ${name}`} onClick={() => { setEditingVariableName(name); setOpenVariables((current) => new Set(current).add(name)); }}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div> <div className={styles.actionControls}><button type="button" className={styles.smallButton} aria-label={`Edit variable ${name}`} onClick={() => { setEditingVariableName(name); setOpenVariables((current) => new Set(current).add(name)); }}>Edit</button><button type="button" className={styles.dangerButton} aria-label={`Delete variable ${name}`} onClick={() => handleDeleteVariable(name)}>Delete</button></div>
</div>} </div>}
</div> </div>
{editingVariableName === name && <VariableEditor name={name} variable={variable} variables={variables} onSave={(nextName, nextVariable) => { setDoc((current) => setVariable(current, nextName, nextVariable, name)); setEditingVariableName(null); }} onCancel={() => setEditingVariableName(null)} />} {editingVariableName === name && <VariableEditor name={name} variable={variable} variables={variables} pages={pages} activePageId={activePageId} onSave={(nextName, nextVariable) => { setDoc((current) => setVariable(current, nextName, nextVariable, name)); setEditingVariableName(null); }} onCancel={() => setEditingVariableName(null)} />}
</div> </div>
))}</div>} ))}</div>}
</div> </div>

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { Variable, VariableType } from '../../types/project'; import type { Page, Variable, VariableType } from '../../types/project';
import { formatVariableDefault, parseVariableDefault, validateVariableName } from './configurationUtils'; import { formatVariableDefault, parseVariableDefault, validateVariableName } from './configurationUtils';
import styles from './ActionInspector.module.css'; import styles from './ActionInspector.module.css';
@ -10,15 +10,19 @@ type Props = {
isNew?: boolean; isNew?: boolean;
onSave: (name: string, variable: Variable) => void; onSave: (name: string, variable: Variable) => void;
onCancel: () => void; onCancel: () => void;
pages: Page[];
activePageId: string;
}; };
const TYPES: VariableType[] = ['string', 'number', 'boolean', 'object', 'array']; const TYPES: VariableType[] = ['string', 'number', 'boolean', 'object', 'array'];
export default function VariableEditor({ name, variable, variables, isNew = false, onSave, onCancel }: Props): React.ReactElement { export default function VariableEditor({ name, variable, variables, isNew = false, onSave, onCancel, pages, activePageId }: Props): React.ReactElement {
const [nameDraft, setNameDraft] = useState(name); const [nameDraft, setNameDraft] = useState(name);
const [type, setType] = useState(variable.type); const [type, setType] = useState(variable.type);
const [defaultDraft, setDefaultDraft] = useState(() => formatVariableDefault(variable)); const [defaultDraft, setDefaultDraft] = useState(() => formatVariableDefault(variable));
const [description, setDescription] = useState(variable.description ?? ''); const [description, setDescription] = useState(variable.description ?? '');
const [scope,setScope]=useState<'global'|'page'>(variable.scope??'global');
const [pageId,setPageId]=useState(variable.pageId??activePageId);
const nameError = validateVariableName(nameDraft, variables, isNew ? undefined : name); const nameError = validateVariableName(nameDraft, variables, isNew ? undefined : name);
let defaultValue: unknown; let defaultValue: unknown;
let defaultError: string | null = null; let defaultError: string | null = null;
@ -29,9 +33,11 @@ export default function VariableEditor({ name, variable, variables, isNew = fals
<div className={styles.configurationEditor} data-testid={`variable-editor-${name || 'new'}`}> <div className={styles.configurationEditor} data-testid={`variable-editor-${name || 'new'}`}>
<label className={styles.formField}><span className={styles.formLabel}>Variable name</span><input className={styles.formInput} aria-label="Variable name" value={nameDraft} onChange={(event) => setNameDraft(event.target.value)} />{nameError && <span className={styles.inlineError} role="alert">{nameError}</span>}</label> <label className={styles.formField}><span className={styles.formLabel}>Variable name</span><input className={styles.formInput} aria-label="Variable name" value={nameDraft} onChange={(event) => setNameDraft(event.target.value)} />{nameError && <span className={styles.inlineError} role="alert">{nameError}</span>}</label>
<label className={styles.formField}><span className={styles.formLabel}>Type</span><select className={styles.formInput} aria-label="Variable type" value={type} onChange={(event) => setType(event.target.value as VariableType)}>{TYPES.map((candidate) => <option key={candidate}>{candidate}</option>)}</select></label> <label className={styles.formField}><span className={styles.formLabel}>Type</span><select className={styles.formInput} aria-label="Variable type" value={type} onChange={(event) => setType(event.target.value as VariableType)}>{TYPES.map((candidate) => <option key={candidate}>{candidate}</option>)}</select></label>
<label className={styles.formField}><span className={styles.formLabel}>Scope</span><select className={styles.formInput} aria-label="Variable scope" value={scope} onChange={event=>setScope(event.target.value as 'global'|'page')}><option value="global">Global all pages</option><option value="page">Page only</option></select></label>
{scope==='page'&&<label className={styles.formField}><span className={styles.formLabel}>Owning page</span><select className={styles.formInput} aria-label="Variable page" value={pageId} onChange={event=>setPageId(event.target.value)}>{pages.map(page=><option key={page.id} value={page.id}>{page.name}</option>)}</select></label>}
<label className={styles.formField}><span className={styles.formLabel}>Default value</span>{type === 'boolean' ? <select className={styles.formInput} aria-label="Variable default value" value={defaultDraft} onChange={(event) => setDefaultDraft(event.target.value)}><option value="">No default</option><option value="true">true</option><option value="false">false</option></select> : <textarea className={styles.formInput} aria-label="Variable default value" value={defaultDraft} onChange={(event) => setDefaultDraft(event.target.value)} placeholder="Optional design-time default" />}{defaultError && <span className={styles.inlineError} role="alert">{defaultError}</span>}<span className={styles.formHint}>Preview copies this value into ephemeral runtime state.</span></label> <label className={styles.formField}><span className={styles.formLabel}>Default value</span>{type === 'boolean' ? <select className={styles.formInput} aria-label="Variable default value" value={defaultDraft} onChange={(event) => setDefaultDraft(event.target.value)}><option value="">No default</option><option value="true">true</option><option value="false">false</option></select> : <textarea className={styles.formInput} aria-label="Variable default value" value={defaultDraft} onChange={(event) => setDefaultDraft(event.target.value)} placeholder="Optional design-time default" />}{defaultError && <span className={styles.inlineError} role="alert">{defaultError}</span>}<span className={styles.formHint}>Preview copies this value into ephemeral runtime state.</span></label>
<label className={styles.formField}><span className={styles.formLabel}>Description</span><input className={styles.formInput} aria-label="Variable description" value={description} onChange={(event) => setDescription(event.target.value)} /></label> <label className={styles.formField}><span className={styles.formLabel}>Description</span><input className={styles.formInput} aria-label="Variable description" value={description} onChange={(event) => setDescription(event.target.value)} /></label>
<div className={styles.actionControls}><button type="button" className={styles.primaryButton} disabled={!valid} onClick={() => onSave(nameDraft, { type, ...(defaultValue !== undefined ? { defaultValue } : {}), ...(description ? { description } : {}) })}>Save variable</button><button type="button" className={styles.smallButton} onClick={onCancel}>Cancel</button></div> <div className={styles.actionControls}><button type="button" className={styles.primaryButton} disabled={!valid || (scope === 'page' && !pageId)} onClick={() => onSave(nameDraft, { type, ...(scope === 'page' ? { scope, pageId } : variable.scope === 'global' ? { scope } : {}), ...(defaultValue !== undefined ? { defaultValue } : {}), ...(description ? { description } : {}) })}>Save variable</button><button type="button" className={styles.smallButton} onClick={onCancel}>Cancel</button></div>
</div> </div>
); );
} }

View File

@ -147,11 +147,11 @@ export function collectReferencedActionIds(doc: ProjectDocument): string[] {
for (const page of doc.project.pages) { for (const page of doc.project.pages) {
for (const event of page.events ?? []) { for (const event of page.events ?? []) {
referencedIds.add(event.actionId); if (event.actionId) referencedIds.add(event.actionId);
} }
for (const component of page.components) { for (const component of page.components) {
for (const event of component.events ?? []) { for (const event of component.events ?? []) {
referencedIds.add(event.actionId); if (event.actionId) referencedIds.add(event.actionId);
} }
} }
} }

View File

@ -48,15 +48,31 @@ export function expandCanvasBounds(
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
function Preview({ document, projectName: suppliedName, executor, published = false }: { document?: ProjectDocument; projectName?: string; executor?: PreviewActionExecutor; published?: boolean }): React.ReactElement { function pageSlug(page: ProjectDocument['project']['pages'][number]): string {
return page.slug || page.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || page.id;
}
function Preview({ document, projectName: suppliedName, executor, published = false, initialPageSlug }: { document?: ProjectDocument; projectName?: string; executor?: PreviewActionExecutor; published?: boolean; initialPageSlug?: string }): React.ReactElement {
const project = useProject(); const project = useProject();
const doc = document ?? project.doc; const doc = document ?? project.doc;
const projectName = suppliedName ?? project.projectName; const projectName = suppliedName ?? project.projectName;
const pages = doc.project.pages; const pages = doc.project.pages;
// Page selection (tabs when multiple pages; single page for MVP) // Page selection (tabs when multiple pages; single page for MVP)
const [activePageIndex, setActivePageIndex] = React.useState(0); const defaultIndex = Math.max(0, pages.findIndex(page => page.id === doc.project.settings?.defaultPageId));
const requestedIndex = initialPageSlug ? pages.findIndex(page => pageSlug(page) === initialPageSlug) : -1;
const [activePageIndex, setActivePageIndex] = React.useState(requestedIndex >= 0 ? requestedIndex : defaultIndex);
const activePage = pages[activePageIndex] ?? null; const activePage = pages[activePageIndex] ?? null;
React.useEffect(() => {
if (!published) return;
const onPopState = () => {
const slug = window.location.pathname.split('/')[3];
const index = slug ? pages.findIndex(page => pageSlug(page) === decodeURIComponent(slug)) : defaultIndex;
if (index >= 0) setActivePageIndex(index);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [defaultIndex, pages, published]);
const bounds = React.useMemo( const bounds = React.useMemo(
() => activePage ? canvasBounds(activePage.components) : { width: 800, height: 400 }, () => activePage ? canvasBounds(activePage.components) : { width: 800, height: 400 },
@ -100,7 +116,18 @@ function Preview({ document, projectName: suppliedName, executor, published = fa
}; };
// ── Preview runtime (binding execution, component state) ───────────────── // ── Preview runtime (binding execution, component state) ─────────────────
const runtime = usePreviewRuntime(doc, executor); const selectPage = React.useCallback((index: number) => {
setActivePageIndex(index);
if (published) {
const next = pages[index];
const appSlug = window.location.pathname.split('/')[2];
if (next && appSlug) window.history.pushState({}, '', `/apps/${appSlug}/${pageSlug(next)}`);
}
}, [pages, published]);
const runtime = usePreviewRuntime(doc, executor, activePage?.id, pageId => {
const index = pages.findIndex(page => page.id === pageId);
if (index >= 0) selectPage(index);
});
return ( return (
<div className={styles.preview}> <div className={styles.preview}>
@ -131,11 +158,11 @@ function Preview({ document, projectName: suppliedName, executor, published = fa
{pages.length > 1 && ( {pages.length > 1 && (
<div className={styles.tabs}> <div className={styles.tabs}>
{pages.map((page, i) => ( {pages.map((page, i) => page.showInNavigation !== false && (
<button <button
key={page.id} key={page.id}
className={[styles.tab, i === activePageIndex ? styles.tabActive : ''].join(' ')} className={[styles.tab, i === activePageIndex ? styles.tabActive : ''].join(' ')}
onClick={() => setActivePageIndex(i)} onClick={() => selectPage(i)}
> >
{page.name} {page.name}
</button> </button>

View File

@ -444,17 +444,17 @@ function applyResponseBindings(
// ── Hook ────────────────────────────────────────────────────────────────────── // ── Hook ──────────────────────────────────────────────────────────────────────
export type PreviewActionExecutor = (action: RestAction, project: {id:string;name:string}, componentState: Record<string, ComponentRuntimeState>, variableState: VariableRuntimeState) => Promise<ProxyResponse>; export type PreviewActionExecutor = (action: RestAction, project: {id:string;name:string}, componentState: Record<string, ComponentRuntimeState>, variableState: VariableRuntimeState, pageId?: string) => Promise<ProxyResponse>;
export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: PreviewActionExecutor = executeAction): PreviewRuntime { export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: PreviewActionExecutor = executeAction, activePageId?: string, onNavigatePage?: (pageId: string) => void): PreviewRuntime {
const [componentState, setComponentState] = useState<Record<string, ComponentRuntimeState>>({}); const [componentState, setComponentState] = useState<Record<string, ComponentRuntimeState>>({});
const [buttonLoading, setButtonLoading] = useState<Record<string, boolean>>({}); const [buttonLoading, setButtonLoading] = useState<Record<string, boolean>>({});
const [actionState, setActionState] = useState<ActionRuntimeStateMap>({}); const [actionState, setActionState] = useState<ActionRuntimeStateMap>({});
const [pageLoadState, setPageLoadState] = useState<PageLoadRuntimeState>({ status: 'idle' }); const [pageLoadState, setPageLoadState] = useState<PageLoadRuntimeState>({ status: 'idle' });
const runAction = useCallback((action: RestAction, componentValues: Record<string, ComponentRuntimeState>, variables: VariableRuntimeState) => { const runAction = useCallback((action: RestAction, componentValues: Record<string, ComponentRuntimeState>, variables: VariableRuntimeState) => {
const project = {id:doc.project.id,name:doc.project.name}; const project = {id:doc.project.id,name:doc.project.name};
return runtimeExecutor === executeAction ? executeAction(action, project) : runtimeExecutor(action, project, componentValues, variables); return runtimeExecutor === executeAction ? executeAction(action, project) : runtimeExecutor(action, project, componentValues, variables, activePageId);
}, [doc.project.id, doc.project.name, runtimeExecutor]); }, [activePageId, doc.project.id, doc.project.name, runtimeExecutor]);
// ── Step 18.1: Runtime variable state ───────────────────────────────────── // ── Step 18.1: Runtime variable state ─────────────────────────────────────
// Separate from project.variables — never mutates the canonical document. // Separate from project.variables — never mutates the canonical document.
@ -473,14 +473,21 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
} }
}, [doc.project.variables]); }, [doc.project.variables]);
// Execute the initial page's configured onLoad action exactly once for this const visitedPages = useRef(new Set<string>());
// mounted Preview runtime. Re-entering Preview creates a fresh runtime. // Execute onLoad on first entry and onEnter on subsequent entries. A fresh
// mounted runtime starts a fresh visit session.
useEffect(() => { useEffect(() => {
const { pages, actions, bindings, variables } = doc.project; const { pages, actions, bindings, variables } = doc.project;
const loadEvent = pages[0]?.events?.find((event) => event.event === 'onLoad'); const page = pages.find(candidate => candidate.id === activePageId) ?? pages[0];
if (!page) return;
const firstEntry = !visitedPages.current.has(page.id);
visitedPages.current.add(page.id);
const loadEvent = page.events?.find((event) => event.event === (firstEntry ? 'onLoad' : 'onEnter'))
?? (firstEntry ? page.events?.find((event) => event.event === 'onEnter') : undefined);
if (!loadEvent) return; if (!loadEvent) return;
const actionId = loadEvent.actionId; const actionId = loadEvent.actionId;
if (!actionId) return;
const action = actions.find((candidate) => candidate.id === actionId); const action = actions.find((candidate) => candidate.id === actionId);
if (!action) { if (!action) {
setPageLoadState({ setPageLoadState({
@ -583,9 +590,7 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
setPageLoadState({ status: 'error', actionId, message }); setPageLoadState({ status: 'error', actionId, message });
clearTargetLoading(); clearTargetLoading();
}); });
// Preview initialization intentionally captures the canonical document at mount. }, [activePageId, doc.project, runAction]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTextInputChange = useCallback((componentId: string, value: string) => { const handleTextInputChange = useCallback((componentId: string, value: string) => {
setComponentState((prev) => ({ setComponentState((prev) => ({
@ -605,8 +610,8 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
(buttonId: string) => { (buttonId: string) => {
const { pages, actions, bindings, variables } = doc.project; const { pages, actions, bindings, variables } = doc.project;
// Collect all components across all pages const activePage = pages.find(page => page.id === activePageId) ?? pages[0];
const allComponents = pages.flatMap((p) => p.components); const allComponents = activePage?.components ?? [];
// Build a name → id map for template interpolation (Step 16) // Build a name → id map for template interpolation (Step 16)
const componentsByName = new Map<string, string>( const componentsByName = new Map<string, string>(
@ -645,7 +650,12 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
// Execute each onClick action (in practice there is usually one) // Execute each onClick action (in practice there is usually one)
for (const clickEvent of clickEvents) { for (const clickEvent of clickEvents) {
if (clickEvent.navigateToPageId) {
onNavigatePage?.(clickEvent.navigateToPageId);
continue;
}
const actionId = clickEvent.actionId; const actionId = clickEvent.actionId;
if (!actionId) continue;
// ── 3. Find the REST action ──────────────────────────────────────── // ── 3. Find the REST action ────────────────────────────────────────
const action: RestAction | undefined = actions.find((a) => a.id === actionId); const action: RestAction | undefined = actions.find((a) => a.id === actionId);
@ -797,7 +807,7 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
}); });
} }
}, },
[doc.project, componentState, variableState, runAction], [doc.project, componentState, variableState, runAction, activePageId, onNavigatePage],
); );
/** /**
@ -838,6 +848,7 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
for (const changeEvent of changeEvents) { for (const changeEvent of changeEvents) {
const actionId = changeEvent.actionId; const actionId = changeEvent.actionId;
if (!actionId) continue;
const action = actions.find((a) => a.id === actionId); const action = actions.find((a) => a.id === actionId);
if (!action) { if (!action) {
console.warn( console.warn(

View File

@ -1,4 +1,43 @@
import React from 'react'; import Preview from '../Preview/Preview'; import Login from '../Auth/Login'; import { executePublished, getPublished, type PublishedRuntime } from '../../api/publishedAppsApi'; import { useAuth } from '../../context/AuthContext'; import type { PreviewActionExecutor, ComponentRuntimeState } from '../Preview/usePreviewRuntime'; import React from 'react';
export default function PublishedApp({slug}:{slug:string}):React.ReactElement{const{user,loading}=useAuth();const[app,setApp]=React.useState<PublishedRuntime|null>(null);const[error,setError]=React.useState('');const[needsAuth,setNeedsAuth]=React.useState(false);React.useEffect(()=>{if(loading)return;getPublished(slug).then(value=>{setApp(value);setNeedsAuth(false)}).catch(e=>{const message=e instanceof Error?e.message:String(e);setNeedsAuth(message.includes('Authentication'));setError(message);});},[slug,user,loading]);if(loading)return <p style={{padding:32}}>Loading</p>;if(needsAuth&&!user)return <Login message="Sign in to open this application."/>;if(error&&!app)return <main style={{padding:32}}><h1>Application unavailable</h1><p>{error}</p></main>;if(!app)return <p style={{padding:32}}>Loading application</p>; import Preview from '../Preview/Preview';
const executor:PreviewActionExecutor=async(action,_project,componentState,variableState)=>{const metadata=action as typeof action&{runtimeInputComponents?:string[];runtimeInputVariables?:string[]};const wantedComponents=new Set(metadata.runtimeInputComponents??[]),wantedVariables=new Set(metadata.runtimeInputVariables??[]);const componentValues:Record<string,unknown>={};for(const page of app.document.project.pages)for(const component of page.components)if(wantedComponents.has(component.name)){const state=componentState[component.id] as ComponentRuntimeState|undefined;componentValues[component.name]=state?.textValue??state?.value??component.properties.defaultValue??'';}const variables=Object.fromEntries(Object.entries(variableState).filter(([key])=>wantedVariables.has(key)));return executePublished(slug,action.id,componentValues,variables);}; import Login from '../Auth/Login';
return <main><div style={{padding:'12px 24px',display:'flex',justifyContent:'space-between',borderBottom:'1px solid #d0d7de'}}><strong>{app.displayName}</strong>{user&&<span>Signed in as {user.displayName}</span>}</div><Preview document={app.document} projectName={app.displayName} executor={executor} published/></main>} import { executePublished, getPublished, type PublishedRuntime } from '../../api/publishedAppsApi';
import { useAuth } from '../../context/AuthContext';
import type { PreviewActionExecutor, ComponentRuntimeState } from '../Preview/usePreviewRuntime';
export default function PublishedApp({ slug, pageSlug }: { slug: string; pageSlug?: string }): React.ReactElement {
const { user, loading } = useAuth();
const [app, setApp] = React.useState<PublishedRuntime | null>(null);
const [error, setError] = React.useState('');
const [needsAuth, setNeedsAuth] = React.useState(false);
React.useEffect(() => {
if (loading) return;
getPublished(slug).then(value => { setApp(value); setNeedsAuth(false); }).catch(e => {
const message = e instanceof Error ? e.message : String(e);
setNeedsAuth(message.includes('Authentication'));
setError(message);
});
}, [slug, user, loading]);
if (loading) return <p style={{ padding: 32 }}>Loading</p>;
if (needsAuth && !user) return <Login message="Sign in to open this application." />;
if (error && !app) return <main style={{ padding: 32 }}><h1>Application unavailable</h1><p>{error}</p></main>;
if (!app) return <p style={{ padding: 32 }}>Loading application</p>;
if (pageSlug && !app.document.project.pages.some(page => (page.slug || page.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || page.id) === pageSlug)) {
return <main style={{ padding: 32 }}><h1>Page not found</h1><p>This application does not contain the requested page.</p><a href={`/apps/${slug}`}>Open its default page</a></main>;
}
const executor: PreviewActionExecutor = async (action, _project, componentState, variableState, pageId) => {
const metadata = action as typeof action & { runtimeInputComponents?: string[]; runtimeInputVariables?: string[] };
const wantedComponents = new Set(metadata.runtimeInputComponents ?? []);
const wantedVariables = new Set(metadata.runtimeInputVariables ?? []);
const componentValues: Record<string, unknown> = {};
for (const page of app.document.project.pages.filter(candidate => candidate.id === pageId)) for (const component of page.components) if (wantedComponents.has(component.name)) {
const state = componentState[component.id] as ComponentRuntimeState | undefined;
componentValues[component.name] = state?.textValue ?? state?.value ?? component.properties.defaultValue ?? '';
}
const variables = Object.fromEntries(Object.entries(variableState).filter(([key]) => wantedVariables.has(key)));
return executePublished(slug, action.id, componentValues, variables, pageId);
};
return <main><div style={{ padding: '12px 24px', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #d0d7de' }}><strong>{app.displayName}</strong>{user && <span>Signed in as {user.displayName}</span>}</div><Preview document={app.document} projectName={app.displayName} executor={executor} published initialPageSlug={pageSlug} /></main>;
}

View File

@ -1,12 +1,20 @@
import React from 'react'; import React from 'react';
import type { ComponentEvent, RestAction } from '../../types/project'; import type { ComponentEvent, Page, RestAction } from '../../types/project';
import { assignButtonOnClick, getButtonOnClickActionId } from './componentEventUtils'; import { getButtonOnClickActionId } from './componentEventUtils';
import styles from './VisualEditor.module.css'; import styles from './VisualEditor.module.css';
type Props = { events: ComponentEvent[] | undefined; actions: RestAction[]; onChange: (events: ComponentEvent[]) => void }; type Props = { events: ComponentEvent[] | undefined; actions: RestAction[]; pages?: Page[]; onChange: (events: ComponentEvent[]) => void };
export default function ButtonEventEditor({ events, actions, onChange }: Props): React.ReactElement { export default function ButtonEventEditor({ events, actions, pages = [], onChange }: Props): React.ReactElement {
const actionId = getButtonOnClickActionId(events); const actionId = getButtonOnClickActionId(events);
const navigationId = events?.find(event => event.event === 'onClick')?.navigateToPageId ?? '';
const selection = navigationId ? `page:${navigationId}` : actionId;
const update = (value: string) => {
const other = (events ?? []).filter(event => event.event !== 'onClick');
if (!value) onChange(other);
else if (value.startsWith('page:')) onChange([...other, { event: 'onClick', navigateToPageId: value.slice(5) }]);
else onChange([...other, { event: 'onClick', actionId: value }]);
};
const missingReference = actionId !== '' && !actions.some((action) => action.id === actionId); const missingReference = actionId !== '' && !actions.some((action) => action.id === actionId);
return ( return (
<div data-testid="button-onclick-editor" style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #d8dee4' }}> <div data-testid="button-onclick-editor" style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #d8dee4' }}>
@ -14,11 +22,12 @@ export default function ButtonEventEditor({ events, actions, onChange }: Props):
<div className={styles.infoRow}> <div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-onclick-action">On click</label> <label className={styles.infoKey} htmlFor="prop-onclick-action">On click</label>
<select id="prop-onclick-action" data-testid="button-onclick-action" className={styles.infoInput} <select id="prop-onclick-action" data-testid="button-onclick-action" className={styles.infoInput}
value={actionId} onChange={(event) => onChange(assignButtonOnClick(events, event.target.value))} value={selection} onChange={(event) => update(event.target.value)}
style={missingReference ? { borderColor: '#b91c1c' } : undefined}> style={missingReference ? { borderColor: '#b91c1c' } : undefined}>
<option value="">No action</option> <option value="">No action</option>
{missingReference && <option value={actionId}>Missing action: {actionId}</option>} {missingReference && <option value={actionId}>Missing action: {actionId}</option>}
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)} <optgroup label="Run action">{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}</optgroup>
<optgroup label="Navigate to page">{pages.map(page => <option key={page.id} value={`page:${page.id}`}>{page.name}</option>)}</optgroup>
</select> </select>
</div> </div>
{actions.length === 0 && !missingReference && <div style={{ fontSize: 10, color: '#57606a' }}>Create a REST action in Actions &amp; Bindings first.</div>} {actions.length === 0 && !missingReference && <div style={{ fontSize: 10, color: '#57606a' }}>Create a REST action in Actions &amp; Bindings first.</div>}

View File

@ -7,6 +7,11 @@ type Props = { events: ComponentEvent[] | undefined; actions: RestAction[]; onCh
export default function PageEventEditor({ events, actions, onChange }: Props): React.ReactElement { export default function PageEventEditor({ events, actions, onChange }: Props): React.ReactElement {
const actionId = getPageOnLoadActionId(events); const actionId = getPageOnLoadActionId(events);
const enterActionId = events?.find(event => event.event === 'onEnter')?.actionId ?? '';
const setEnter = (nextActionId: string) => onChange([
...(events ?? []).filter(event => event.event !== 'onEnter'),
...(nextActionId ? [{ event: 'onEnter', actionId: nextActionId }] : []),
]);
const missingReference = actionId !== '' && !actions.some((action) => action.id === actionId); const missingReference = actionId !== '' && !actions.some((action) => action.id === actionId);
return ( return (
<div className={styles.pageEventEditor} data-testid="page-onload-editor"> <div className={styles.pageEventEditor} data-testid="page-onload-editor">
@ -18,6 +23,11 @@ export default function PageEventEditor({ events, actions, onChange }: Props): R
{missingReference && <option value={actionId}>Missing action: {actionId}</option>} {missingReference && <option value={actionId}>Missing action: {actionId}</option>}
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)} {actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
</select> </select>
<label className={styles.pageEventLabel} htmlFor="page-onenter-action">On enter</label>
<select id="page-onenter-action" className={styles.pageEventSelect} value={enterActionId} onChange={event => setEnter(event.target.value)}>
<option value="">No action</option>
{actions.map(action => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
</select>
{actions.length === 0 && !missingReference && <span className={styles.pageEventHint}>Create a REST action first.</span>} {actions.length === 0 && !missingReference && <span className={styles.pageEventHint}>Create a REST action first.</span>}
{missingReference && <span className={styles.pageEventError} role="alert">Missing action &quot;{actionId}&quot;.</span>} {missingReference && <span className={styles.pageEventError} role="alert">Missing action &quot;{actionId}&quot;.</span>}
</div> </div>

View File

@ -0,0 +1,8 @@
import React from 'react';
import { useProject } from '../../context/ProjectContext';
export default function PageManager():React.ReactElement{
const{doc,activePageId,setActivePage,addPage,duplicatePage,updatePage,movePage,deletePage,setDefaultPage}=useProject();
const pages=[...doc.project.pages].sort((a,b)=>(a.order??0)-(b.order??0));
return <section aria-label="Project pages" style={{padding:'10px 12px',borderBottom:'1px solid #d0d7de',background:'#f6f8fa'}}><div style={{display:'flex',gap:8,alignItems:'center',flexWrap:'wrap'}}><strong>Pages</strong><select aria-label="Active page" value={activePageId} onChange={e=>setActivePage(e.target.value)}>{pages.map(page=><option key={page.id} value={page.id}>{page.name}{doc.project.settings.defaultPageId===page.id||(!doc.project.settings.defaultPageId&&pages[0].id===page.id)?' (default)':''}</option>)}</select><button type="button" onClick={addPage}>+ Add page</button><button type="button" onClick={()=>duplicatePage(activePageId)}>Duplicate</button><button type="button" disabled={pages.findIndex(p=>p.id===activePageId)===0} onClick={()=>movePage(activePageId,-1)}>Move left</button><button type="button" disabled={pages.findIndex(p=>p.id===activePageId)===pages.length-1} onClick={()=>movePage(activePageId,1)}>Move right</button><button type="button" onClick={()=>setDefaultPage(activePageId)}>Set default</button><button type="button" disabled={pages.length===1} onClick={()=>{const page=pages.find(p=>p.id===activePageId);if(page&&window.confirm(`Delete page "${page.name}"? Page components and page-scoped variables will be removed.`))deletePage(activePageId)}}>Delete page</button></div>{pages.filter(page=>page.id===activePageId).map(page=><div key={page.id} style={{display:'flex',gap:10,alignItems:'center',marginTop:10,flexWrap:'wrap'}}><label>Name <input value={page.name} onChange={e=>updatePage(page.id,{name:e.target.value})}/></label><label>URL slug <input pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={page.slug??page.name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')} onChange={e=>updatePage(page.id,{slug:e.target.value})}/></label><label><input type="checkbox" checked={page.showInNavigation!==false} onChange={e=>updatePage(page.id,{showInNavigation:e.target.checked})}/> Show in navigation</label><code>{page.id}</code></div>)}</section>;
}

View File

@ -10,6 +10,7 @@ import PageEventEditor from './PageEventEditor';
import ComponentDeleteDialog from './ComponentDeleteDialog'; import ComponentDeleteDialog from './ComponentDeleteDialog';
import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils'; import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils';
import ValidationSummary from '../ValidationSummary'; import ValidationSummary from '../ValidationSummary';
import PageManager from './PageManager';
// ── Dropdown options row editor ─────────────────────────────────────────────── // ── Dropdown options row editor ───────────────────────────────────────────────
@ -492,6 +493,7 @@ function VisualEditor(): React.ReactElement {
<div className={styles.editor}> <div className={styles.editor}>
{/* ── Project toolbar (New / Save / Load) ───────────────────── */} {/* ── Project toolbar (New / Save / Load) ───────────────────── */}
<ProjectToolbar /> <ProjectToolbar />
<PageManager />
<ValidationSummary /> <ValidationSummary />
{/* ── Editor toolbar (page info) ────────────────────────────── */} {/* ── Editor toolbar (page info) ────────────────────────────── */}
@ -771,6 +773,7 @@ function VisualEditor(): React.ReactElement {
<ButtonEventEditor <ButtonEventEditor
events={selectedComponent.events} events={selectedComponent.events}
actions={doc.project.actions} actions={doc.project.actions}
pages={doc.project.pages}
onChange={(events) => updateComponentEvents(selectedComponent.id, events)} onChange={(events) => updateComponentEvents(selectedComponent.id, events)}
/> />
)} )}

View File

@ -53,6 +53,14 @@ export type ProjectContextValue = {
// Active page (first page, MVP) // Active page (first page, MVP)
activePage: Page; activePage: Page;
activePageId: string;
setActivePage: (id:string)=>void;
addPage:()=>void;
duplicatePage:(id:string)=>void;
updatePage:(id:string,changes:Partial<Pick<Page,'name'|'slug'|'showInNavigation'>>)=>void;
movePage:(id:string,direction:-1|1)=>void;
deletePage:(id:string)=>void;
setDefaultPage:(id:string)=>void;
// Canvas mutation actions (add/move/remove components) // Canvas mutation actions (add/move/remove components)
canvas: CanvasActions; canvas: CanvasActions;
@ -99,6 +107,7 @@ export function ProjectProvider({
const [projectRowId, setProjectRowId] = useState<number | null>(null); const [projectRowId, setProjectRowId] = useState<number | null>(null);
const [projectName, setProjectNameState] = useState('New Project'); const [projectName, setProjectNameState] = useState('New Project');
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [activePageId,setActivePageId]=useState(()=>makeNewDoc('temp').project.pages[0].id);
// ── Canvas mutations (delegate to useCanvasActions) ─────────────────────── // ── Canvas mutations (delegate to useCanvasActions) ───────────────────────
// Wrap setDoc so we can track dirty state on every canvas mutation // Wrap setDoc so we can track dirty state on every canvas mutation
@ -111,7 +120,17 @@ export function ProjectProvider({
[], [],
); );
const canvas = useCanvasActions(doc, setDocAndMarkDirty); const canvas = useCanvasActions(doc, setDocAndMarkDirty,activePageId);
useEffect(()=>{if(!doc.project.pages.some(page=>page.id===activePageId))setActivePageId(doc.project.settings.defaultPageId??doc.project.pages[0]?.id??'');},[doc.project.pages,doc.project.settings.defaultPageId,activePageId]);
const setActivePage=useCallback((id:string)=>{setActivePageId(id);setSelectedId(null);},[]);
const slugify=(value:string)=>value.toLowerCase().trim().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')||'page';
const uniqueSlug=useCallback((base:string,pages:Page[])=>{const root=slugify(base);let slug=root,index=2;const used=new Set(pages.map(page=>page.slug??slugify(page.name)));while(used.has(slug))slug=`${root}-${index++}`;return slug;},[]);
const addPage=useCallback(()=>{const id=`page_${Date.now()}`;setDocAndMarkDirty(prev=>{const name=`Page ${prev.project.pages.length+1}`;return{...prev,project:{...prev.project,pages:[...prev.project.pages,{id,name,slug:uniqueSlug(name,prev.project.pages),order:prev.project.pages.length,showInNavigation:true,components:[]}]}}});setActivePageId(id);setSelectedId(null);},[setDocAndMarkDirty,uniqueSlug]);
const duplicatePage=useCallback((id:string)=>{let nextId='';setDocAndMarkDirty(prev=>{const source=prev.project.pages.find(page=>page.id===id);if(!source)return prev;nextId=`page_${Date.now()}`;const name=`${source.name} Copy`;const clone:Page={...source,id:nextId,name,slug:uniqueSlug(name,prev.project.pages),order:prev.project.pages.length,components:source.components.map(component=>({...component,id:`${component.id}_${Date.now()}`}))};return{...prev,project:{...prev.project,pages:[...prev.project.pages,clone]}}});if(nextId)setActivePageId(nextId);setSelectedId(null);},[setDocAndMarkDirty,uniqueSlug]);
const updatePage=useCallback((id:string,changes:Partial<Pick<Page,'name'|'slug'|'showInNavigation'>>)=>setDocAndMarkDirty(prev=>({...prev,project:{...prev.project,pages:prev.project.pages.map(page=>page.id===id?{...page,...changes}:page)}})),[setDocAndMarkDirty]);
const movePage=useCallback((id:string,direction:-1|1)=>setDocAndMarkDirty(prev=>{const pages=[...prev.project.pages].sort((a,b)=>(a.order??0)-(b.order??0));const index=pages.findIndex(page=>page.id===id),target=index+direction;if(index<0||target<0||target>=pages.length)return prev;[pages[index],pages[target]]=[pages[target],pages[index]];return{...prev,project:{...prev.project,pages:pages.map((page,order)=>({...page,order}))}};}),[setDocAndMarkDirty]);
const deletePage=useCallback((id:string)=>setDocAndMarkDirty(prev=>{if(prev.project.pages.length<=1)return prev;const pages=prev.project.pages.filter(page=>page.id!==id).map((page,order)=>({...page,order}));const variables=Object.fromEntries(Object.entries(prev.project.variables).filter(([,variable])=>variable.pageId!==id));const defaultPageId=prev.project.settings.defaultPageId===id?pages[0].id:prev.project.settings.defaultPageId;setActivePageId(pages[0].id);setSelectedId(null);return{...prev,project:{...prev.project,pages,variables,settings:{...prev.project.settings,defaultPageId}}};}),[setDocAndMarkDirty]);
const setDefaultPage=useCallback((id:string)=>setDocAndMarkDirty(prev=>({...prev,project:{...prev.project,settings:{...prev.project.settings,defaultPageId:id}}})),[setDocAndMarkDirty]);
// ── Component selection ─────────────────────────────────────────────────── // ── Component selection ───────────────────────────────────────────────────
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
@ -181,6 +200,7 @@ export function ProjectProvider({
setProjectRowId(null); setProjectRowId(null);
setProjectNameState(name); setProjectNameState(name);
setSelectedId(null); setSelectedId(null);
setActivePageId(freshDoc.project.pages[0].id);
setIsDirty(false); setIsDirty(false);
}, []); }, []);
@ -238,6 +258,7 @@ export function ProjectProvider({
throw new Error('Project data is not valid JSON.'); throw new Error('Project data is not valid JSON.');
} }
setDoc(loadedDoc); setDoc(loadedDoc);
setActivePageId(loadedDoc.project.settings.defaultPageId??loadedDoc.project.pages[0].id);
setProjectRowId(row.id); setProjectRowId(row.id);
setProjectNameState(row.name); setProjectNameState(row.name);
setIsDirty(false); setIsDirty(false);
@ -269,6 +290,8 @@ export function ProjectProvider({
projectName, projectName,
setProjectName, setProjectName,
activePage: canvas.activePage, activePage: canvas.activePage,
activePageId,
setActivePage,addPage,duplicatePage,updatePage,movePage,deletePage,setDefaultPage,
canvas, canvas,
selectedId, selectedId,
selectComponent, selectComponent,
@ -293,6 +316,7 @@ export function ProjectProvider({
projectName, projectName,
setProjectName, setProjectName,
canvas, canvas,
activePageId,setActivePage,addPage,duplicatePage,updatePage,movePage,deletePage,setDefaultPage,
selectedId, selectedId,
selectComponent, selectComponent,
isDirty, isDirty,

View File

@ -72,9 +72,9 @@ export type CanvasActions = {
export function useCanvasActions( export function useCanvasActions(
doc: ProjectDocument, doc: ProjectDocument,
setDoc: React.Dispatch<React.SetStateAction<ProjectDocument>>, setDoc: React.Dispatch<React.SetStateAction<ProjectDocument>>,
activePageId?: string,
): CanvasActions { ): CanvasActions {
// Always operate on the first page for this MVP step const activePage = doc.project.pages.find((page)=>page.id===activePageId)??doc.project.pages[0];
const activePage = doc.project.pages[0];
const updatePage = useCallback( const updatePage = useCallback(
(updater: (p: Page) => Page) => { (updater: (p: Page) => Page) => {
@ -82,11 +82,11 @@ export function useCanvasActions(
...prev, ...prev,
project: { project: {
...prev.project, ...prev.project,
pages: prev.project.pages.map((p, i) => (i === 0 ? updater(p) : p)), pages: prev.project.pages.map((p) => (p.id === activePage.id ? updater(p) : p)),
}, },
})); }));
}, },
[setDoc], [setDoc,activePage.id],
); );
const addComponent = useCallback( const addComponent = useCallback(

View File

@ -34,6 +34,10 @@ export interface Variable {
defaultValue?: unknown; defaultValue?: unknown;
/** Optional documentation string shown in tooling. */ /** Optional documentation string shown in tooling. */
description?: string; description?: string;
/** Omitted is backward-compatible global scope. */
scope?: 'global' | 'page';
/** Required for page-scoped variables. */
pageId?: string;
} }
// ── Binding types ───────────────────────────────────────────────────────────── // ── Binding types ─────────────────────────────────────────────────────────────
@ -99,7 +103,9 @@ export type ComponentEvent = {
/** Name of the triggering event (e.g. "onClick", "onChange", "onLoad"). */ /** Name of the triggering event (e.g. "onClick", "onChange", "onLoad"). */
event: string; event: string;
/** ID of the project-level action to execute when this event fires. */ /** ID of the project-level action to execute when this event fires. */
actionId: string; actionId?: string;
/** Client-side navigation target; mutually exclusive with actionId for guided authoring. */
navigateToPageId?: string;
/** /**
* Reserved legacy field retained for schema compatibility. * Reserved legacy field retained for schema compatibility.
* Preview does not consume inputMap. Request inputs use templates directly * Preview does not consume inputMap. Request inputs use templates directly
@ -280,6 +286,8 @@ export type Page = {
name: string; name: string;
description?: string; description?: string;
order?: number; order?: number;
slug?: string;
showInNavigation?: boolean;
components: CanvasComponent[]; components: CanvasComponent[];
/** Page-level lifecycle event handlers (for example, onLoad → actionId). */ /** Page-level lifecycle event handlers (for example, onLoad → actionId). */
events?: ComponentEvent[]; events?: ComponentEvent[];

View File

@ -48,7 +48,7 @@ for (const file of linkedFiles) {
} }
} }
for (let id = 1; id <= 18; id += 1) { for (let id = 1; id <= 19; id += 1) {
if (!traceability.includes(`| R${id} |`)) failures.push(`docs/MVP_TRACEABILITY.md: missing R${id}`); if (!traceability.includes(`| R${id} |`)) failures.push(`docs/MVP_TRACEABILITY.md: missing R${id}`);
} }
@ -57,4 +57,4 @@ if (failures.length > 0) {
process.exit(1); process.exit(1);
} }
console.log('MVP governance check passed: scope, R1-R18 traceability, classifications, and links are consistent.'); console.log('MVP governance check passed: scope, R1-R19 traceability, classifications, and links are consistent.');

View File

@ -43,7 +43,6 @@
"maxLength": 200, "maxLength": 200,
"examples": ["Concert Workflow Launcher"] "examples": ["Concert Workflow Launcher"]
}, },
"description": { "description": {
"type": "string", "type": "string",
"description": "Optional free-text description of the project's purpose.", "description": "Optional free-text description of the project's purpose.",
@ -106,6 +105,17 @@
"description": "Human-readable page name shown in navigation.", "description": "Human-readable page name shown in navigation.",
"minLength": 1 "minLength": 1
}, },
"slug": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
"maxLength": 80,
"description": "Stable URL segment for this page. Older documents may omit it; runtimes derive a deterministic slug from name/id."
},
"showInNavigation": {
"type": "boolean",
"description": "Whether this page appears in automatic application navigation. This is not an authorization control.",
"default": true
},
"description": { "description": {
"type": "string", "type": "string",
"description": "Optional description of this page's purpose.", "description": "Optional description of this page's purpose.",
@ -422,8 +432,18 @@
"Variable": { "Variable": {
"type": "object", "type": "object",
"description": "A named global variable declaration.", "description": "A named global or page-owned runtime variable declaration.",
"required": ["type"], "required": ["type"],
"allOf": [
{
"if": { "properties": { "scope": { "const": "page" } }, "required": ["scope"] },
"then": { "required": ["pageId"] }
},
{
"if": { "properties": { "scope": { "const": "global" } }, "required": ["scope"] },
"then": { "not": { "required": ["pageId"] } }
}
],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"type": { "type": {
@ -431,6 +451,17 @@
"enum": ["string", "number", "boolean", "object", "array"], "enum": ["string", "number", "boolean", "object", "array"],
"description": "The runtime type of this variable." "description": "The runtime type of this variable."
}, },
"scope": {
"type": "string",
"enum": ["global", "page"],
"description": "Runtime scope. Omitted means global for backward compatibility.",
"default": "global"
},
"pageId": {
"type": "string",
"minLength": 1,
"description": "Owning page ID. Required when scope is page and forbidden for global variables."
},
"defaultValue": { "defaultValue": {
"description": "Design-time default. Must be compatible with the declared type." "description": "Design-time default. Must be compatible with the declared type."
}, },
@ -476,7 +507,11 @@
"Event": { "Event": {
"type": "object", "type": "object",
"description": "An event handler that fires when a lifecycle or user-interaction event occurs.", "description": "An event handler that fires when a lifecycle or user-interaction event occurs.",
"required": ["event", "actionId"], "required": ["event"],
"anyOf": [
{ "required": ["actionId"] },
{ "required": ["navigateToPageId"] }
],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"event": { "event": {
@ -489,6 +524,11 @@
"description": "ID of the project-level action to execute when this event fires.", "description": "ID of the project-level action to execute when this event fires.",
"minLength": 1 "minLength": 1
}, },
"navigateToPageId": {
"type": "string",
"description": "Typed client-side page-navigation target used instead of a REST action.",
"minLength": 1
},
"inputMap": { "inputMap": {
"type": "object", "type": "object",
"description": "Reserved legacy field retained for compatibility. Preview request inputs use templates in REST action request fields; inputMap is not executed.", "description": "Reserved legacy field retained for compatibility. Preview request inputs use templates in REST action request fields; inputMap is not executed.",