From d4fae4e1cc8e359f9c38a4fa7bb280119d1faf86 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 14:53:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(showcase):=20page-local=20state=20demo=20+?= =?UTF-8?q?=20flip=20variables=20@experimental=E2=86=92live=20(ADR-0049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page-variable end-to-end loop is now implemented in objectui (page state injected as `page.`, element:record_picker writes via `source`, visibility gates live), so promote PageSchema.variables from @experimental to live and ship a worked example. - spec: replace the @experimental marker on PageSchema.variables with live docs; enrich PageVariableSchema (binding direction: a variable names its writer component via `source`). - spec liveness ledger: flip page.variables experimental→live with the objectui consumer cited (packages/spec/liveness/page.json) — passes check:liveness. - showcase: new master/detail page `showcase_page_variables` — a record picker writes `selectedProjectId`; a detail panel gates on `page.selectedProjectId`. Wired into navigation + the app config. - dogfood test: asserts the wiring is coherent and the gating predicates flip with the variable (mutually exclusive empty/detail states) + registration. Co-Authored-By: Claude Opus 4.8 --- .changeset/page-variables-live.md | 11 ++ examples/app-showcase/objectstack.config.ts | 4 +- examples/app-showcase/src/apps/index.ts | 1 + examples/app-showcase/src/pages/index.ts | 1 + .../src/pages/page-variables.page.ts | 106 ++++++++++++++++ .../app-showcase/test/page-variables.test.ts | 120 ++++++++++++++++++ packages/spec/liveness/page.json | 4 +- packages/spec/src/ui/page.zod.ts | 41 +++--- 8 files changed, 268 insertions(+), 20 deletions(-) create mode 100644 .changeset/page-variables-live.md create mode 100644 examples/app-showcase/src/pages/page-variables.page.ts create mode 100644 examples/app-showcase/test/page-variables.test.ts diff --git a/.changeset/page-variables-live.md b/.changeset/page-variables-live.md new file mode 100644 index 0000000000..54a310715b --- /dev/null +++ b/.changeset/page-variables-live.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': patch +--- + +Promote `PageSchema.variables` from @experimental to live (ADR-0049) + +Page-local state is now wired end-to-end (runtime in objectui#1957: page +variables are injected into the visible/CEL expression context as `page.`, +and `element:record_picker` writes a variable via its `source` binding). The +spec docs are updated to describe the now-live behaviour and the binding +direction, and the liveness ledger entry is flipped `experimental → live`. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 46bb94b293..005e4ea1b1 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js'; import { allReports } from './src/reports/index.js'; import { allActions } from './src/actions/index.js'; -import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage } from './src/pages/index.js'; +import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage } from './src/pages/index.js'; import { allFlows } from './src/flows/index.js'; import { allWebhooks } from './src/webhooks/index.js'; import { allHooks } from './src/hooks/index.js'; @@ -156,7 +156,7 @@ export default defineStack({ apps: [ShowcaseApp], portals: allPortals, views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews], - pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage], + pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage], dashboards: [ChartGalleryDashboard, OpsDashboard], books: allBooks, datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index 3cbcecff4b..06bbdc09c9 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -66,6 +66,7 @@ export const ShowcaseApp = App.create({ children: [ { id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' }, { id: 'nav_styling_gallery', type: 'page', pageName: 'showcase_styling_gallery', label: 'Styling (ADR-0065)', icon: 'palette' }, + { id: 'nav_page_variables', type: 'page', pageName: 'showcase_page_variables', label: 'Page Variables', icon: 'mouse-pointer-click' }, { id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' }, // ADR-0047 interface mode: same object as nav_tasks, curated surface. { id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' }, diff --git a/examples/app-showcase/src/pages/index.ts b/examples/app-showcase/src/pages/index.ts index 49587ce389..fd20f296fe 100644 --- a/examples/app-showcase/src/pages/index.ts +++ b/examples/app-showcase/src/pages/index.ts @@ -15,6 +15,7 @@ export { MyWorkPage } from './my-work.page.js'; export { SettingsPage } from './settings.page.js'; export { StylingGalleryPage } from './styling-gallery.page.js'; export { CommandCenterPage } from './command-center.page.js'; +export { PageVariablesPage } from './page-variables.page.js'; export { TaskBoardPage, TaskCalendarPage, diff --git a/examples/app-showcase/src/pages/page-variables.page.ts b/examples/app-showcase/src/pages/page-variables.page.ts new file mode 100644 index 0000000000..d2f852beae --- /dev/null +++ b/examples/app-showcase/src/pages/page-variables.page.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePage } from '@objectstack/spec/ui'; + +/** + * Page Variables — master/detail driven by page-local state (PageSchema.variables). + * + * Demonstrates the end-to-end page-variable loop: + * 1. `variables` declares `selectedProjectId`, fed by the component whose id + * is `project_picker` (PageVariableSchema.source = that component id). + * 2. `element:record_picker` (id: project_picker) writes the chosen project's + * id into that variable on selection. + * 3. Sibling components gate on `page.selectedProjectId` via `visibility`: + * the empty-state hint shows while nothing is picked; the detail panel + * appears the moment a project is chosen — re-evaluated live, no reload. + * + * This is the canonical low-code "filtered detail" pattern: one picker drives + * what the rest of the page shows, with no custom code. + */ +export const PageVariablesPage = definePage({ + name: 'showcase_page_variables', + label: 'Page Variables (Master/Detail)', + icon: 'mouse-pointer-click', + type: 'app', + template: 'header-sidebar-main', + isDefault: false, + // Page-local state. `selectedProjectId` is written by the `project_picker` + // element (source = its component id) and read by predicates as `page.selectedProjectId`. + variables: [ + { name: 'selectedProjectId', type: 'record_id', source: 'project_picker' }, + ], + regions: [ + { + name: 'header', + width: 'full', + components: [ + { + type: 'page:header', + properties: { + title: 'Page Variables', + subtitle: 'Pick a project — page-local state drives what shows below, live.', + }, + }, + ], + }, + { + name: 'main', + width: 'large', + components: [ + { + type: 'element:text', + properties: { + content: + 'This page declares a `selectedProjectId` variable. The record picker writes the selected project into it; the detail panel below is gated on `page.selectedProjectId` and only appears once you choose. No custom code — just metadata.', + variant: 'body', + }, + }, + { + type: 'element:record_picker', + id: 'project_picker', + dataSource: { object: 'showcase_project', limit: 50 }, + properties: { + label: 'Project', + labelField: 'name', + placeholder: 'Choose a project…', + }, + }, + // Empty state — visible until a project is chosen. + { + type: 'element:text', + id: 'empty_hint', + visibility: "page.selectedProjectId == ''", + properties: { + content: '↑ Select a project above to reveal its detail panel.', + variant: 'caption', + }, + }, + // Detail panel — gated on the page variable. Appears once a project is picked. + { + type: 'element:divider', + id: 'detail_divider', + visibility: "page.selectedProjectId != ''", + }, + { + type: 'element:text', + id: 'detail_heading', + visibility: "page.selectedProjectId != ''", + properties: { + content: '✓ Project selected', + variant: 'subheading', + }, + }, + { + type: 'element:text', + id: 'detail_body', + visibility: "page.selectedProjectId != ''", + properties: { + content: + 'This panel is gated on `page.selectedProjectId != ""`. It became visible the instant the picker wrote the variable — the same page-local state any other component (or its data filter) can read.', + variant: 'body', + }, + }, + ], + }, + ], +}); diff --git a/examples/app-showcase/test/page-variables.test.ts b/examples/app-showcase/test/page-variables.test.ts new file mode 100644 index 0000000000..5d27166474 --- /dev/null +++ b/examples/app-showcase/test/page-variables.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { PageVariablesPage } from '../src/pages/index.js'; +import stack from '../objectstack.config.js'; +import { ShowcaseApp } from '../src/apps/index.js'; + +/** + * Dogfood gate for page-local state (PageSchema.variables, ADR-0049). + * + * In the "demonstrated AND verified" spirit: it is not enough that the page + * *declares* a variable and a picker. These assertions prove the wiring is + * coherent end-to-end — the variable names a real writer component, the gating + * predicates reference that variable, and (crucially) the predicates actually + * gate the way the demo claims when the variable flips. A page that merely + * looked plausible but mis-wired the `source` id or inverted a predicate would + * pass a shape-only check but fail here. + */ + +type AnyComponent = { + type: string; + id?: string; + visibility?: unknown; + [k: string]: unknown; +}; + +/** Flatten every component across the page's regions. */ +function allComponents(page: typeof PageVariablesPage): AnyComponent[] { + const out: AnyComponent[] = []; + for (const region of page.regions ?? []) { + for (const c of region.components ?? []) out.push(c as AnyComponent); + } + return out; +} + +/** Extract a predicate's CEL source whether stored as a bare string or the + * normalized `{ dialect, source }` envelope that definePage produces. */ +function predicateSource(visibility: unknown): string | undefined { + if (typeof visibility === 'string') return visibility; + if (visibility && typeof visibility === 'object' && typeof (visibility as any).source === 'string') { + return (visibility as any).source; + } + return undefined; +} + +/** Evaluate a (simple, comparison-only) CEL predicate against a page scope. + * Sufficient for the `==` / `!=` predicates this page uses. */ +function evalPredicate(source: string, page: Record): boolean { + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const fn = new Function('page', `"use strict"; return (${source});`) as ( + p: Record, + ) => boolean; + return Boolean(fn(page)); +} + +describe('Page Variables showcase — page-local state (ADR-0049)', () => { + it('parses and declares the selectedProjectId variable bound to the picker', () => { + expect(PageVariablesPage.name).toBe('showcase_page_variables'); + + const vars = PageVariablesPage.variables ?? []; + const sel = vars.find((v) => v.name === 'selectedProjectId'); + expect(sel, 'selectedProjectId variable must exist').toBeTruthy(); + expect(sel!.type).toBe('record_id'); + // source names the WRITER component id. + expect(sel!.source).toBe('project_picker'); + }); + + it('ships a record picker whose id matches the variable source', () => { + const picker = allComponents(PageVariablesPage).find((c) => c.id === 'project_picker'); + expect(picker, 'a component with id project_picker must exist').toBeTruthy(); + expect(picker!.type).toBe('element:record_picker'); + // It binds to a real object so the picker has something to load. + expect((picker as any).dataSource?.object).toBe('showcase_project'); + }); + + it('gates its detail panel on the variable — hidden until a project is picked, shown after', () => { + const comps = allComponents(PageVariablesPage); + const gated = comps.filter((c) => c.visibility !== undefined); + // Empty-hint + divider + heading + body — every gated node references the variable. + expect(gated.length).toBeGreaterThanOrEqual(2); + + const empty = { page: { selectedProjectId: '' } as Record }; + const picked = { page: { selectedProjectId: 'proj_42' } as Record }; + + let shownWhenEmpty = 0; + let shownWhenPicked = 0; + for (const c of gated) { + const src = predicateSource(c.visibility); + expect(src, `gated component ${c.id} must carry a predicate`).toBeTruthy(); + // Every gating predicate is about the page variable. + expect(src).toContain('page.selectedProjectId'); + if (evalPredicate(src!, empty.page)) shownWhenEmpty++; + if (evalPredicate(src!, picked.page)) shownWhenPicked++; + } + + // The empty-state hint shows only when nothing is picked; the detail panel + // (divider + heading + body) shows only after a pick. So the visible set + // strictly flips between the two states — proving the variable drives the UI. + expect(shownWhenEmpty).toBeGreaterThanOrEqual(1); // the empty hint + expect(shownWhenPicked).toBeGreaterThanOrEqual(1); // the detail panel + // The empty-state predicate and the detail predicates are mutually exclusive: + // no gated node is visible in BOTH states. + for (const c of gated) { + const src = predicateSource(c.visibility)!; + const inEmpty = evalPredicate(src, empty.page); + const inPicked = evalPredicate(src, picked.page); + expect(inEmpty && inPicked, `component ${c.id} should not be visible in both states`).toBe(false); + } + }); + + it('is registered in the app config and reachable from navigation', () => { + const pageNames = (stack.pages ?? []).map((p: any) => p.name); + expect(pageNames).toContain('showcase_page_variables'); + + // Navigation has a link to the page. + const flat = JSON.stringify(ShowcaseApp.navigation ?? []); + expect(flat).toContain('showcase_page_variables'); + }); +}); diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 284211be12..e23fec7254 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -23,8 +23,8 @@ "note": "objectui PageView (app-shell/src/views/PageView.tsx) + components/renderers/layout/page.tsx dispatch on the page kind (record/home/app/utility/list). Only these render — roadmap types removed (PAGE_TYPE_ROADMAP, framework#2265)." }, "variables": { - "status": "experimental", - "note": "Page-local state. objectui mounts PageVariablesProvider + usePageVariables (react/src/hooks/usePageVariables.tsx) with default-value init, but no shipped consumer reads/writes them end-to-end (no element:record_picker writer; not injected into the visible/CEL context). Spec field is @experimental. Complete-or-remove tracked separately." + "status": "live", + "note": "Page-local state (ADR-0049). objectui injects variables into the visible/CEL expression context as `page.` (react/src/SchemaRenderer.tsx) and ships element:record_picker, which writes its selection into the variable named by PageVariableSchema.source (components/src/renderers/basic/record-picker.tsx); usePageVariableBinding resolves writer->variable. Showcase: showcase_page_variables (master/detail — picker drives a detail panel's visibility). objectui#1957." }, "object": { "status": "live", diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 984f3b77f8..3a57c17015 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -120,15 +120,26 @@ export const PageComponentSchema = lazySchema(() => z.object({ /** * Page Variable Schema - * Defines local state for the page. - * Variables can be bound to interactive elements (e.g. element:record_picker, element:filter). + * Local, in-memory page state. Runtime-live (ADR-0049): the renderer mounts the + * declared variables, exposes them to expressions as `page.`, and lets an + * interactive element write one via `source`. A write re-evaluates dependent + * `visibility` / binding predicates immediately — the master/detail and + * filtered-dashboard pattern with no custom code. + * + * Binding direction: a variable names the **writer** component, not the other + * way round. `{ name: 'selectedProjectId', source: 'project_picker' }` means the + * component whose `id` is `project_picker` (e.g. an `element:record_picker`) + * writes the user's selection into `selectedProjectId`; predicates then read it + * as `page.selectedProjectId`. */ export const PageVariableSchema = lazySchema(() => z.object({ - name: z.string().describe('Variable name'), + name: z.string().describe('Variable name. Exposed to expressions as `page.`.'), type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'record_id']).default('string'), - defaultValue: z.unknown().optional(), - /** Source element binding (e.g. element:record_picker writes to this variable) */ - source: z.string().optional().describe('Component ID that writes to this variable'), + defaultValue: z.unknown().optional() + .describe('Initial value. Defaults to a type-appropriate empty value when omitted.'), + /** Source element binding — the component id that writes this variable. */ + source: z.string().optional() + .describe('Component id that writes this variable (e.g. an element:record_picker whose `id` matches).'), })); // BlankPageLayoutItemSchema / BlankPageLayoutSchema removed — the `blank` page @@ -292,17 +303,15 @@ export const PageSchema = lazySchema(() => z.object({ type: PageTypeSchema.default('record').describe('Page type'), /** - * Page-local state variables. - * - * @experimental The state container is wired (the runtime mounts a - * `PageVariablesProvider` + `usePageVariables` hook with default-value init), - * but the end-to-end loop is not proven: no shipped element writes a variable - * (e.g. `element:record_picker` → `source`) and page variables are not yet - * injected into the `visible`/binding expression context. Authoring variables - * is therefore mostly inert today — treat as a preview surface until a - * consumer + an example/proof land. + * Page-local state variables (ADR-0049). Runtime-live: the renderer mounts the + * declared variables, exposes each to expressions as `page.`, and lets an + * interactive element write one via its `source` binding (e.g. + * `element:record_picker` → `source`). A write re-evaluates dependent + * `visibility` / binding predicates immediately — the master/detail and + * filtered-dashboard pattern, with no custom code. See {@link PageVariableSchema}. */ - variables: z.array(PageVariableSchema).optional().describe('Local page state variables (experimental — see schema doc)'), + variables: z.array(PageVariableSchema).optional() + .describe('Local page state, exposed to expressions as `page.` and writable by interactive elements via `source` (master/detail, filtered dashboards).'), /** Context */ object: z.string().optional().describe('Bound object (for Record pages)'),