Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/page-variables-live.md
Original file line number Diff line number Diff line change
@@ -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.<var>`,
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`.
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/src/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions examples/app-showcase/src/pages/page-variables.page.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
],
},
],
});
120 changes: 120 additions & 0 deletions examples/app-showcase/test/page-variables.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<string, unknown>,
) => 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<string, unknown> };
const picked = { page: { selectedProjectId: 'proj_42' } as Record<string, unknown> };

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');
});
});
4 changes: 2 additions & 2 deletions packages/spec/liveness/page.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.<var>` (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",
Expand Down
41 changes: 25 additions & 16 deletions packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>`, 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.<name>`.'),
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
Expand Down Expand Up @@ -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.<name>`, 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.<name>` and writable by interactive elements via `source` (master/detail, filtered dashboards).'),

/** Context */
object: z.string().optional().describe('Bound object (for Record pages)'),
Expand Down
Loading