From 60273d75284b08cb24dca534da46c46a2e60e8bd Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 7 Aug 2026 15:20:30 -0400 Subject: [PATCH] Add multi-page application authoring --- MVP_SCOPE.md | 2 +- ROADMAP.md | 24 ++++++---- backend/src/lib/validateProject.ts | 29 +++++++++--- backend/src/routes/publishedApps.ts | 3 +- docs/MVP_TRACEABILITY.md | 5 +- frontend/src/App.tsx | 4 +- frontend/src/api/publishedAppsApi.ts | 2 +- .../ActionInspector/ActionInspector.tsx | 13 ++--- .../ActionInspector/VariableEditor.tsx | 12 +++-- .../ActionInspector/actionEditorUtils.ts | 4 +- frontend/src/components/Preview/Preview.tsx | 37 +++++++++++++-- .../components/Preview/usePreviewRuntime.ts | 37 ++++++++++----- .../src/components/Published/PublishedApp.tsx | 47 +++++++++++++++++-- .../VisualEditor/ButtonEventEditor.tsx | 21 ++++++--- .../VisualEditor/PageEventEditor.tsx | 10 ++++ .../components/VisualEditor/PageManager.tsx | 8 ++++ .../components/VisualEditor/VisualEditor.tsx | 3 ++ frontend/src/context/ProjectContext.tsx | 26 +++++++++- frontend/src/store/useProjectStore.ts | 8 ++-- frontend/src/types/project.ts | 10 +++- scripts/check-mvp-governance.mjs | 4 +- shared/schemas/conductor-project.schema.json | 46 ++++++++++++++++-- 22 files changed, 284 insertions(+), 71 deletions(-) create mode 100644 frontend/src/components/VisualEditor/PageManager.tsx diff --git a/MVP_SCOPE.md b/MVP_SCOPE.md index 831232a..b1b9209 100644 --- a/MVP_SCOPE.md +++ b/MVP_SCOPE.md @@ -83,7 +83,7 @@ Approved on 2026-08-07 for Slice 7a: ## 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`. diff --git a/ROADMAP.md b/ROADMAP.md index 06bf399..881dd79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 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 7b](SLICE7b.md) | Browser-based first-run administrator setup | Planned | Fresh-install setup, takeover prevention, recovery validation, and explicit sign-off | -| [Slice 7c](SLICE7c.md) | Multi-page applications and variable scope | Planned | Page authoring, scoped runtime, deep links, security 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 | 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 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 -- [ ] 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. -- [ ] Create an authenticated session after successful setup without exposing passwords or session values. -- [ ] Preserve the CLI bootstrap only as a documented emergency/recovery path. -- [ ] Complete fresh-install, concurrent-takeover, existing-installation, restart, and manual acceptance coverage. +- [x] Replace the normal Docker-command bootstrap experience with a secure browser-based first-run setup screen. +- [x] Make initial-admin creation atomic and available only while the installation contains no users. +- [x] Create an authenticated session after successful setup without exposing passwords or session values. +- [x] Preserve the CLI bootstrap only as a documented emergency/recovery path. +- [x] Complete fresh-install, concurrent-takeover, existing-installation, restart, and manual acceptance coverage. ### 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. - [ ] 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 -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 diff --git a/backend/src/lib/validateProject.ts b/backend/src/lib/validateProject.ts index 6a12b67..1a0eb5c 100644 --- a/backend/src/lib/validateProject.ts +++ b/backend/src/lib/validateProject.ts @@ -84,6 +84,15 @@ function semanticIssues(doc: JsonObject): ValidationIssue[] { add(issues, 'DUPLICATE_PAGE_ID', '/project/pages', `Page id "${id}" is duplicated.`); for (const name of duplicates(pages.map((page) => page.name))) 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))) add(issues, 'DUPLICATE_ACTION_ID', '/project/actions', `Action id "${id}" is duplicated.`); 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))) add(issues, 'DUPLICATE_COMPONENT_ID', '/project/pages', `Component id "${id}" is duplicated across the project.`); - for (const [name, matches] of componentsByName) { - if (matches.length > 1) - add(issues, 'DUPLICATE_COMPONENT_NAME', '/project/pages', `Component name "${name}" is duplicated; runtime references require project-wide unique names.`); - } + pages.forEach((page, pageIndex) => { + for (const name of duplicates((page.components as JsonObject[]).map(component => component.name))) + 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) => { (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.`); + 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)) add(issues, 'EVENT_UNSUPPORTED', `${path}/${index}/event`, `Event "${event.event}" is not supported here.`); 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'); }); }; - 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 }) => { const componentPath = `/project/pages/${pageIndex}/components/${componentIndex}`; checkEvents(component.events, `/project/pages/${pageIndex}/components/${componentIndex}/events`, new Set(['onClick'])); diff --git a/backend/src/routes/publishedApps.ts b/backend/src/routes/publishedApps.ts index ad3b1c2..f5e8530 100644 --- a/backend/src/routes/publishedApps.ts +++ b/backend/src/routes/publishedApps.ts @@ -6,7 +6,8 @@ import { ProxyPolicyError, sanitizeUrl } from '../lib/proxyPolicy'; import { recordExecution, type ExecutionOutcome } from '../db/executions'; const router=Router(); -type Snapshot={schemaVersion:string;project:{id:string;name:string;description?:string;pages:unknown[];actions:RestActionInput[];bindings:unknown[];variables:Record;settings?:Record}}; +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;settings?:Record}}; 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 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)};} diff --git a/docs/MVP_TRACEABILITY.md b/docs/MVP_TRACEABILITY.md index d4fd66e..006da8f 100644 --- a/docs/MVP_TRACEABILITY.md +++ b/docs/MVP_TRACEABILITY.md @@ -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 | | 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 | -| 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 | -| 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 | +| 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 | 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b6d281..44ed2d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,8 +53,8 @@ function AppContent(): React.ReactElement { // ProjectProvider wraps the entire app so all editor views share one context. function AuthenticatedApp(): React.ReactElement { const { user, loading, setupRequired } = useAuth(); - const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)\/?$/); - if (publishedMatch) return ; + const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)(?:\/([^/]+))?\/?$/); + if (publishedMatch) return ; if (loading) return

Loading…

; if (setupRequired) return ; if (!user) return ; diff --git a/frontend/src/api/publishedAppsApi.ts b/frontend/src/api/publishedAppsApi.ts index 3b8a6e7..e6fd5da 100644 --- a/frontend/src/api/publishedAppsApi.ts +++ b/frontend/src/api/publishedAppsApi.ts @@ -6,4 +6,4 @@ export type PublishedRuntime=PublishedSummary&{document:ProjectDocument}; async function json(response:Response):Promise{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;} export const listPublished=()=>apiFetch('/api/published-apps').then(json); export const getPublished=(slug:string)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}`).then(json); -export const executePublished=(slug:string,actionId:string,componentValues:Record,variableValues:Record)=>apiFetch(`/api/published-apps/${encodeURIComponent(slug)}/actions/${encodeURIComponent(actionId)}/execute`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({componentValues,variableValues})}).then(json); +export const executePublished=(slug:string,actionId:string,componentValues:Record,variableValues:Record,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); diff --git a/frontend/src/components/ActionInspector/ActionInspector.tsx b/frontend/src/components/ActionInspector/ActionInspector.tsx index 9183449..a711de7 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.tsx +++ b/frontend/src/components/ActionInspector/ActionInspector.tsx @@ -128,12 +128,12 @@ function computeDiagnostics( const triggeredActionIds = new Set(); for (const page of pages) { for (const ev of page.events ?? []) { - triggeredActionIds.add(ev.actionId); + if (ev.actionId) triggeredActionIds.add(ev.actionId); } } for (const comp of allComponents) { 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 ev of comp.events ?? []) { if (ev.event === 'onClick') { + if (!ev.actionId) continue; const list = onClickTriggers.get(ev.actionId) ?? []; list.push(comp.name); onClickTriggers.set(ev.actionId, list); @@ -406,7 +407,7 @@ function computeDiagnostics( for (const comp of allComponents) { 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) const relatedBindings = bindings.filter((b) => { const parsed = parseActionSourcePath(b.source); @@ -906,7 +907,7 @@ function BindingCard({ // ── Main component ──────────────────────────────────────────────────────────── function ActionInspector(): React.ReactElement { - const { doc, setDoc } = useProject(); + const { doc, setDoc, activePageId } = useProject(); const { actions, bindings, pages, variables } = doc.project; const allComponents = useMemo( @@ -1206,7 +1207,7 @@ function ActionInspector(): React.ReactElement {
{Object.keys(variables).length}
} diff --git a/frontend/src/components/ActionInspector/VariableEditor.tsx b/frontend/src/components/ActionInspector/VariableEditor.tsx index 94f6132..df07f6b 100644 --- a/frontend/src/components/ActionInspector/VariableEditor.tsx +++ b/frontend/src/components/ActionInspector/VariableEditor.tsx @@ -1,5 +1,5 @@ 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 styles from './ActionInspector.module.css'; @@ -10,15 +10,19 @@ type Props = { isNew?: boolean; onSave: (name: string, variable: Variable) => void; onCancel: () => void; + pages: Page[]; + activePageId: string; }; 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 [type, setType] = useState(variable.type); const [defaultDraft, setDefaultDraft] = useState(() => formatVariableDefault(variable)); 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); let defaultValue: unknown; let defaultError: string | null = null; @@ -29,9 +33,11 @@ export default function VariableEditor({ name, variable, variables, isNew = fals
+ + {scope==='page'&&}