Add multi-page application authoring
This commit is contained in:
parent
a3372b33c2
commit
60273d7528
@ -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`.
|
||||
|
||||
|
||||
24
ROADMAP.md
24
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
|
||||
|
||||
|
||||
@ -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<string>) => {
|
||||
(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']));
|
||||
|
||||
@ -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<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;
|
||||
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)};}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 <ProjectProvider><PublishedApp slug={decodeURIComponent(publishedMatch[1])}/></ProjectProvider>;
|
||||
const publishedMatch = window.location.pathname.match(/^\/apps\/([^/]+)(?:\/([^/]+))?\/?$/);
|
||||
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 (setupRequired) return <FirstRunSetup />;
|
||||
if (!user) return <Login />;
|
||||
|
||||
@ -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>;}
|
||||
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 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>);
|
||||
|
||||
@ -128,12 +128,12 @@ function computeDiagnostics(
|
||||
const triggeredActionIds = new Set<string>();
|
||||
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 {
|
||||
<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 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]) => (
|
||||
<div className={styles.recordGroup} key={name}>
|
||||
<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>}
|
||||
</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>
|
||||
|
||||
@ -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
|
||||
<div className={styles.configurationEditor} data-testid={`variable-editor-${name || 'new'}`}>
|
||||
<label className={styles.formField}><span className={styles.formLabel}>Variable name</span><input className={styles.formInput} aria-label="Variable name" value={nameDraft} onChange={(event) => setNameDraft(event.target.value)} />{nameError && <span className={styles.inlineError} role="alert">{nameError}</span>}</label>
|
||||
<label className={styles.formField}><span className={styles.formLabel}>Type</span><select className={styles.formInput} aria-label="Variable type" value={type} onChange={(event) => setType(event.target.value as VariableType)}>{TYPES.map((candidate) => <option key={candidate}>{candidate}</option>)}</select></label>
|
||||
<label className={styles.formField}><span className={styles.formLabel}>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}>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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -147,11 +147,11 @@ export function collectReferencedActionIds(doc: ProjectDocument): string[] {
|
||||
|
||||
for (const page of doc.project.pages) {
|
||||
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 event of component.events ?? []) {
|
||||
referencedIds.add(event.actionId);
|
||||
if (event.actionId) referencedIds.add(event.actionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,15 +48,31 @@ export function expandCanvasBounds(
|
||||
|
||||
// ── 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 doc = document ?? project.doc;
|
||||
const projectName = suppliedName ?? project.projectName;
|
||||
const pages = doc.project.pages;
|
||||
|
||||
// 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;
|
||||
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(
|
||||
() => 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) ─────────────────
|
||||
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 (
|
||||
<div className={styles.preview}>
|
||||
@ -131,11 +158,11 @@ function Preview({ document, projectName: suppliedName, executor, published = fa
|
||||
|
||||
{pages.length > 1 && (
|
||||
<div className={styles.tabs}>
|
||||
{pages.map((page, i) => (
|
||||
{pages.map((page, i) => page.showInNavigation !== false && (
|
||||
<button
|
||||
key={page.id}
|
||||
className={[styles.tab, i === activePageIndex ? styles.tabActive : ''].join(' ')}
|
||||
onClick={() => setActivePageIndex(i)}
|
||||
onClick={() => selectPage(i)}
|
||||
>
|
||||
{page.name}
|
||||
</button>
|
||||
|
||||
@ -444,17 +444,17 @@ function applyResponseBindings(
|
||||
|
||||
// ── 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 [buttonLoading, setButtonLoading] = useState<Record<string, boolean>>({});
|
||||
const [actionState, setActionState] = useState<ActionRuntimeStateMap>({});
|
||||
const [pageLoadState, setPageLoadState] = useState<PageLoadRuntimeState>({ status: 'idle' });
|
||||
const runAction = useCallback((action: RestAction, componentValues: Record<string, ComponentRuntimeState>, variables: VariableRuntimeState) => {
|
||||
const project = {id:doc.project.id,name:doc.project.name};
|
||||
return runtimeExecutor === executeAction ? executeAction(action, project) : runtimeExecutor(action, project, componentValues, variables);
|
||||
}, [doc.project.id, doc.project.name, runtimeExecutor]);
|
||||
return runtimeExecutor === executeAction ? executeAction(action, project) : runtimeExecutor(action, project, componentValues, variables, activePageId);
|
||||
}, [activePageId, doc.project.id, doc.project.name, runtimeExecutor]);
|
||||
|
||||
// ── Step 18.1: Runtime variable state ─────────────────────────────────────
|
||||
// Separate from project.variables — never mutates the canonical document.
|
||||
@ -473,14 +473,21 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
|
||||
}
|
||||
}, [doc.project.variables]);
|
||||
|
||||
// Execute the initial page's configured onLoad action exactly once for this
|
||||
// mounted Preview runtime. Re-entering Preview creates a fresh runtime.
|
||||
const visitedPages = useRef(new Set<string>());
|
||||
// Execute onLoad on first entry and onEnter on subsequent entries. A fresh
|
||||
// mounted runtime starts a fresh visit session.
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
const actionId = loadEvent.actionId;
|
||||
if (!actionId) return;
|
||||
const action = actions.find((candidate) => candidate.id === actionId);
|
||||
if (!action) {
|
||||
setPageLoadState({
|
||||
@ -583,9 +590,7 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
|
||||
setPageLoadState({ status: 'error', actionId, message });
|
||||
clearTargetLoading();
|
||||
});
|
||||
// Preview initialization intentionally captures the canonical document at mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [activePageId, doc.project, runAction]);
|
||||
|
||||
const handleTextInputChange = useCallback((componentId: string, value: string) => {
|
||||
setComponentState((prev) => ({
|
||||
@ -605,8 +610,8 @@ export function usePreviewRuntime(doc: ProjectDocument, runtimeExecutor: Preview
|
||||
(buttonId: string) => {
|
||||
const { pages, actions, bindings, variables } = doc.project;
|
||||
|
||||
// Collect all components across all pages
|
||||
const allComponents = pages.flatMap((p) => p.components);
|
||||
const activePage = pages.find(page => page.id === activePageId) ?? pages[0];
|
||||
const allComponents = activePage?.components ?? [];
|
||||
|
||||
// Build a name → id map for template interpolation (Step 16)
|
||||
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)
|
||||
for (const clickEvent of clickEvents) {
|
||||
if (clickEvent.navigateToPageId) {
|
||||
onNavigatePage?.(clickEvent.navigateToPageId);
|
||||
continue;
|
||||
}
|
||||
const actionId = clickEvent.actionId;
|
||||
if (!actionId) continue;
|
||||
|
||||
// ── 3. Find the REST action ────────────────────────────────────────
|
||||
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) {
|
||||
const actionId = changeEvent.actionId;
|
||||
if (!actionId) continue;
|
||||
const action = actions.find((a) => a.id === actionId);
|
||||
if (!action) {
|
||||
console.warn(
|
||||
|
||||
@ -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';
|
||||
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>;
|
||||
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);};
|
||||
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 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';
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
import React from 'react';
|
||||
import type { ComponentEvent, RestAction } from '../../types/project';
|
||||
import { assignButtonOnClick, getButtonOnClickActionId } from './componentEventUtils';
|
||||
import type { ComponentEvent, Page, RestAction } from '../../types/project';
|
||||
import { getButtonOnClickActionId } from './componentEventUtils';
|
||||
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 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);
|
||||
return (
|
||||
<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}>
|
||||
<label className={styles.infoKey} htmlFor="prop-onclick-action">On click</label>
|
||||
<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}>
|
||||
<option value="">No action</option>
|
||||
{missingReference && <option value={actionId}>Missing action: {actionId}</option>}
|
||||
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
|
||||
<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>
|
||||
</div>
|
||||
{actions.length === 0 && !missingReference && <div style={{ fontSize: 10, color: '#57606a' }}>Create a REST action in Actions & Bindings first.</div>}
|
||||
|
||||
@ -7,6 +7,11 @@ type Props = { events: ComponentEvent[] | undefined; actions: RestAction[]; onCh
|
||||
|
||||
export default function PageEventEditor({ events, actions, onChange }: Props): React.ReactElement {
|
||||
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);
|
||||
return (
|
||||
<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>}
|
||||
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
|
||||
</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>}
|
||||
{missingReference && <span className={styles.pageEventError} role="alert">Missing action "{actionId}".</span>}
|
||||
</div>
|
||||
|
||||
8
frontend/src/components/VisualEditor/PageManager.tsx
Normal file
8
frontend/src/components/VisualEditor/PageManager.tsx
Normal 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>;
|
||||
}
|
||||
@ -10,6 +10,7 @@ import PageEventEditor from './PageEventEditor';
|
||||
import ComponentDeleteDialog from './ComponentDeleteDialog';
|
||||
import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils';
|
||||
import ValidationSummary from '../ValidationSummary';
|
||||
import PageManager from './PageManager';
|
||||
|
||||
// ── Dropdown options row editor ───────────────────────────────────────────────
|
||||
|
||||
@ -492,6 +493,7 @@ function VisualEditor(): React.ReactElement {
|
||||
<div className={styles.editor}>
|
||||
{/* ── Project toolbar (New / Save / Load) ───────────────────── */}
|
||||
<ProjectToolbar />
|
||||
<PageManager />
|
||||
<ValidationSummary />
|
||||
|
||||
{/* ── Editor toolbar (page info) ────────────────────────────── */}
|
||||
@ -771,6 +773,7 @@ function VisualEditor(): React.ReactElement {
|
||||
<ButtonEventEditor
|
||||
events={selectedComponent.events}
|
||||
actions={doc.project.actions}
|
||||
pages={doc.project.pages}
|
||||
onChange={(events) => updateComponentEvents(selectedComponent.id, events)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -53,6 +53,14 @@ export type ProjectContextValue = {
|
||||
|
||||
// Active page (first page, MVP)
|
||||
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: CanvasActions;
|
||||
@ -99,6 +107,7 @@ export function ProjectProvider({
|
||||
const [projectRowId, setProjectRowId] = useState<number | null>(null);
|
||||
const [projectName, setProjectNameState] = useState('New Project');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [activePageId,setActivePageId]=useState(()=>makeNewDoc('temp').project.pages[0].id);
|
||||
|
||||
// ── Canvas mutations (delegate to useCanvasActions) ───────────────────────
|
||||
// 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 ───────────────────────────────────────────────────
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@ -181,6 +200,7 @@ export function ProjectProvider({
|
||||
setProjectRowId(null);
|
||||
setProjectNameState(name);
|
||||
setSelectedId(null);
|
||||
setActivePageId(freshDoc.project.pages[0].id);
|
||||
setIsDirty(false);
|
||||
}, []);
|
||||
|
||||
@ -238,6 +258,7 @@ export function ProjectProvider({
|
||||
throw new Error('Project data is not valid JSON.');
|
||||
}
|
||||
setDoc(loadedDoc);
|
||||
setActivePageId(loadedDoc.project.settings.defaultPageId??loadedDoc.project.pages[0].id);
|
||||
setProjectRowId(row.id);
|
||||
setProjectNameState(row.name);
|
||||
setIsDirty(false);
|
||||
@ -269,6 +290,8 @@ export function ProjectProvider({
|
||||
projectName,
|
||||
setProjectName,
|
||||
activePage: canvas.activePage,
|
||||
activePageId,
|
||||
setActivePage,addPage,duplicatePage,updatePage,movePage,deletePage,setDefaultPage,
|
||||
canvas,
|
||||
selectedId,
|
||||
selectComponent,
|
||||
@ -293,6 +316,7 @@ export function ProjectProvider({
|
||||
projectName,
|
||||
setProjectName,
|
||||
canvas,
|
||||
activePageId,setActivePage,addPage,duplicatePage,updatePage,movePage,deletePage,setDefaultPage,
|
||||
selectedId,
|
||||
selectComponent,
|
||||
isDirty,
|
||||
|
||||
@ -72,9 +72,9 @@ export type CanvasActions = {
|
||||
export function useCanvasActions(
|
||||
doc: ProjectDocument,
|
||||
setDoc: React.Dispatch<React.SetStateAction<ProjectDocument>>,
|
||||
activePageId?: string,
|
||||
): CanvasActions {
|
||||
// Always operate on the first page for this MVP step
|
||||
const activePage = doc.project.pages[0];
|
||||
const activePage = doc.project.pages.find((page)=>page.id===activePageId)??doc.project.pages[0];
|
||||
|
||||
const updatePage = useCallback(
|
||||
(updater: (p: Page) => Page) => {
|
||||
@ -82,11 +82,11 @@ export function useCanvasActions(
|
||||
...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(
|
||||
|
||||
@ -34,6 +34,10 @@ export interface Variable {
|
||||
defaultValue?: unknown;
|
||||
/** Optional documentation string shown in tooling. */
|
||||
description?: string;
|
||||
/** Omitted is backward-compatible global scope. */
|
||||
scope?: 'global' | 'page';
|
||||
/** Required for page-scoped variables. */
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
// ── Binding types ─────────────────────────────────────────────────────────────
|
||||
@ -99,7 +103,9 @@ export type ComponentEvent = {
|
||||
/** Name of the triggering event (e.g. "onClick", "onChange", "onLoad"). */
|
||||
event: string;
|
||||
/** ID of the project-level action to execute when this event fires. */
|
||||
actionId: string;
|
||||
actionId?: string;
|
||||
/** Client-side navigation target; mutually exclusive with actionId for guided authoring. */
|
||||
navigateToPageId?: string;
|
||||
/**
|
||||
* Reserved legacy field retained for schema compatibility.
|
||||
* Preview does not consume inputMap. Request inputs use templates directly
|
||||
@ -280,6 +286,8 @@ export type Page = {
|
||||
name: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
slug?: string;
|
||||
showInNavigation?: boolean;
|
||||
components: CanvasComponent[];
|
||||
/** Page-level lifecycle event handlers (for example, onLoad → actionId). */
|
||||
events?: ComponentEvent[];
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -57,4 +57,4 @@ if (failures.length > 0) {
|
||||
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.');
|
||||
|
||||
@ -43,7 +43,6 @@
|
||||
"maxLength": 200,
|
||||
"examples": ["Concert Workflow Launcher"]
|
||||
},
|
||||
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional free-text description of the project's purpose.",
|
||||
@ -106,6 +105,17 @@
|
||||
"description": "Human-readable page name shown in navigation.",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "Optional description of this page's purpose.",
|
||||
@ -422,8 +432,18 @@
|
||||
|
||||
"Variable": {
|
||||
"type": "object",
|
||||
"description": "A named global variable declaration.",
|
||||
"description": "A named global or page-owned runtime variable declaration.",
|
||||
"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,
|
||||
"properties": {
|
||||
"type": {
|
||||
@ -431,6 +451,17 @@
|
||||
"enum": ["string", "number", "boolean", "object", "array"],
|
||||
"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": {
|
||||
"description": "Design-time default. Must be compatible with the declared type."
|
||||
},
|
||||
@ -476,7 +507,11 @@
|
||||
"Event": {
|
||||
"type": "object",
|
||||
"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,
|
||||
"properties": {
|
||||
"event": {
|
||||
@ -489,6 +524,11 @@
|
||||
"description": "ID of the project-level action to execute when this event fires.",
|
||||
"minLength": 1
|
||||
},
|
||||
"navigateToPageId": {
|
||||
"type": "string",
|
||||
"description": "Typed client-side page-navigation target used instead of a REST action.",
|
||||
"minLength": 1
|
||||
},
|
||||
"inputMap": {
|
||||
"type": "object",
|
||||
"description": "Reserved legacy field retained for compatibility. Preview request inputs use templates in REST action request fields; inputMap is not executed.",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user