Skip to content

Commit 88b2edb

Browse files
committed
feat: add PageVariablesProvider and usePageVariables hook for managing page-level state variables
1 parent 19f8c4b commit 88b2edb

File tree

6 files changed

+270
-8
lines changed

6 files changed

+270
-8
lines changed

packages/react/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@
99
export * from './useExpression';
1010
export * from './useActionRunner';
1111
export * from './useNavigationOverlay';
12+
export * from './usePageVariables';
1213

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
10+
import type { PageVariable } from '@object-ui/types';
11+
12+
/**
13+
* Page variables context value.
14+
* Provides access to page-level state variables.
15+
*/
16+
export interface PageVariablesContextValue {
17+
/** Current variable values */
18+
variables: Record<string, any>;
19+
/** Set a single variable value */
20+
setVariable: (name: string, value: any) => void;
21+
/** Set multiple variable values at once */
22+
setVariables: (updates: Record<string, any>) => void;
23+
/** Reset all variables to their default values */
24+
resetVariables: () => void;
25+
}
26+
27+
const PageVariablesContext = createContext<PageVariablesContextValue | null>(null);
28+
29+
/**
30+
* Initialize page variables from their definitions.
31+
* Sets each variable to its defaultValue or type-appropriate default.
32+
*/
33+
function initializeVariables(definitions?: PageVariable[]): Record<string, any> {
34+
if (!definitions || definitions.length === 0) return {};
35+
36+
const initial: Record<string, any> = {};
37+
for (const def of definitions) {
38+
if (def.defaultValue !== undefined) {
39+
initial[def.name] = def.defaultValue;
40+
} else {
41+
// Type-appropriate defaults
42+
switch (def.type) {
43+
case 'number':
44+
initial[def.name] = 0;
45+
break;
46+
case 'boolean':
47+
initial[def.name] = false;
48+
break;
49+
case 'object':
50+
initial[def.name] = {};
51+
break;
52+
case 'array':
53+
initial[def.name] = [];
54+
break;
55+
case 'string':
56+
default:
57+
initial[def.name] = '';
58+
break;
59+
}
60+
}
61+
}
62+
return initial;
63+
}
64+
65+
/**
66+
* Props for PageVariablesProvider
67+
*/
68+
export interface PageVariablesProviderProps {
69+
/** Variable definitions from PageSchema.variables */
70+
definitions?: PageVariable[];
71+
/** Child components */
72+
children: React.ReactNode;
73+
}
74+
75+
/**
76+
* PageVariablesProvider — Provides page-level state variables to the component tree.
77+
*
78+
* Initializes variables from their definitions and provides read/write access
79+
* to all child components via the usePageVariables hook.
80+
*
81+
* @example
82+
* ```tsx
83+
* <PageVariablesProvider definitions={[
84+
* { name: 'selectedId', type: 'string', defaultValue: '' },
85+
* { name: 'count', type: 'number', defaultValue: 0 },
86+
* ]}>
87+
* <MyComponents />
88+
* </PageVariablesProvider>
89+
* ```
90+
*/
91+
export const PageVariablesProvider: React.FC<PageVariablesProviderProps> = ({
92+
definitions,
93+
children,
94+
}) => {
95+
const [variables, setVariablesState] = useState<Record<string, any>>(() =>
96+
initializeVariables(definitions)
97+
);
98+
99+
const setVariable = useCallback((name: string, value: any) => {
100+
setVariablesState((prev) => ({ ...prev, [name]: value }));
101+
}, []);
102+
103+
const setVariables = useCallback((updates: Record<string, any>) => {
104+
setVariablesState((prev) => ({ ...prev, ...updates }));
105+
}, []);
106+
107+
const resetVariables = useCallback(() => {
108+
setVariablesState(initializeVariables(definitions));
109+
}, [definitions]);
110+
111+
const value = useMemo<PageVariablesContextValue>(
112+
() => ({ variables, setVariable, setVariables, resetVariables }),
113+
[variables, setVariable, setVariables, resetVariables]
114+
);
115+
116+
return (
117+
<PageVariablesContext.Provider value={value}>
118+
{children}
119+
</PageVariablesContext.Provider>
120+
);
121+
};
122+
123+
PageVariablesProvider.displayName = 'PageVariablesProvider';
124+
125+
/**
126+
* Hook to access page-level variables.
127+
*
128+
* Returns the current variable values and setter functions.
129+
* Returns a no-op fallback if used outside a PageVariablesProvider.
130+
*
131+
* @example
132+
* ```tsx
133+
* const { variables, setVariable } = usePageVariables();
134+
* const userId = variables.selectedId;
135+
* setVariable('selectedId', '123');
136+
* ```
137+
*/
138+
export function usePageVariables(): PageVariablesContextValue {
139+
const ctx = useContext(PageVariablesContext);
140+
if (!ctx) {
141+
// Graceful fallback — allows components to work outside a Page
142+
return {
143+
variables: {},
144+
setVariable: () => {},
145+
setVariables: () => {},
146+
resetVariables: () => {},
147+
};
148+
}
149+
return ctx;
150+
}
151+
152+
/**
153+
* Hook to check if a PageVariablesProvider is available.
154+
*/
155+
export function useHasPageVariables(): boolean {
156+
return useContext(PageVariablesContext) !== null;
157+
}

packages/types/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export type {
9494
AspectRatioSchema,
9595
LayoutSchema,
9696
PageSchema,
97+
PageType,
98+
PageRegion,
99+
PageRegionWidth,
100+
PageVariable,
97101
} from './layout';
98102

99103
// ============================================================================

packages/types/src/layout.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -424,35 +424,64 @@ export interface AspectRatioSchema extends BaseSchema {
424424
children?: SchemaNode | SchemaNode[];
425425
}
426426

427+
/**
428+
* Page Type
429+
* Determines page behavior and default layout template.
430+
* Aligned with @objectstack/spec Page.type
431+
*/
432+
export type PageType = 'record' | 'home' | 'app' | 'utility';
433+
434+
/**
435+
* Page Variable
436+
* Local page state that can be read/written by components and expressions.
437+
* Aligned with @objectstack/spec PageVariableSchema
438+
*/
439+
export interface PageVariable {
440+
/** Variable name */
441+
name: string;
442+
/** Variable type @default 'string' */
443+
type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
444+
/** Default value for initialization */
445+
defaultValue?: any;
446+
}
447+
448+
/**
449+
* Page Region Size
450+
* Aligned with @objectstack/spec PageRegionSchema.width
451+
*/
452+
export type PageRegionWidth = 'small' | 'medium' | 'large' | 'full';
453+
427454
/**
428455
* Page Region (Header, Sidebar, Main, etc)
456+
* Aligned with @objectstack/spec PageRegionSchema
429457
*/
430458
export interface PageRegion {
431459
/**
432-
* Region name/id
460+
* Region name/id (e.g. "sidebar", "main", "header")
433461
*/
434462
name: string;
435463
/**
436-
* Region type
464+
* Region type — semantic role for layout rendering
437465
*/
438466
type?: 'header' | 'sidebar' | 'main' | 'footer' | 'aside';
439467
/**
440-
* Width (flex basis)
468+
* Region width (spec-aligned enum)
441469
*/
442-
width?: string;
470+
width?: PageRegionWidth | string;
443471
/**
444472
* Components in this region
445473
*/
446474
components: SchemaNode[];
447475
/**
448-
* CSS class
476+
* CSS class overrides
449477
*/
450478
className?: string;
451479
}
452480

453481
/**
454482
* Page layout component
455-
* Top-level container for a page route
483+
* Top-level container for a page route.
484+
* Aligned with @objectstack/spec PageSchema
456485
*/
457486
export interface PageSchema extends BaseSchema {
458487
type: 'page';
@@ -461,13 +490,33 @@ export interface PageSchema extends BaseSchema {
461490
*/
462491
title?: string;
463492
/**
464-
* Page icon (Lucide icon name)
465-
*/
493+
* Page icon (Lucide icon name)
494+
*/
466495
icon?: string;
467496
/**
468497
* Page description
469498
*/
470499
description?: string;
500+
/**
501+
* Page type — determines default layout and behavior
502+
* @default 'record'
503+
*/
504+
pageType?: PageType;
505+
/**
506+
* Bound object name (for record pages)
507+
* Provides record context to components in regions
508+
*/
509+
object?: string;
510+
/**
511+
* Layout template name (e.g. "default", "header-sidebar-main")
512+
* @default 'default'
513+
*/
514+
template?: string;
515+
/**
516+
* Local page state variables
517+
* Initialized on mount and available to all components via context
518+
*/
519+
variables?: PageVariable[];
471520
/**
472521
* Page layout regions
473522
* (Aligned with @objectstack/spec Page.regions)
@@ -481,6 +530,15 @@ export interface PageSchema extends BaseSchema {
481530
* Alternative content prop
482531
*/
483532
children?: SchemaNode | SchemaNode[];
533+
/**
534+
* Whether this is the default page for the object/app
535+
* @default false
536+
*/
537+
isDefault?: boolean;
538+
/**
539+
* Profiles that can access this page
540+
*/
541+
assignedProfiles?: string[];
484542
}
485543

486544
/**

packages/types/src/zod/index.zod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ export {
7878
ResizablePanelSchema,
7979
ResizableSchema,
8080
AspectRatioSchema,
81+
PageRegionWidthSchema,
82+
PageRegionSchema,
83+
PageVariableSchema,
84+
PageTypeSchema,
8185
PageSchema,
8286
LayoutSchema,
8387
} from './layout.zod.js';

packages/types/src/zod/layout.zod.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,16 +227,54 @@ export const AspectRatioSchema = BaseSchema.extend({
227227
children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'),
228228
});
229229

230+
/**
231+
* Page Region Width Schema
232+
*/
233+
export const PageRegionWidthSchema = z.enum(['small', 'medium', 'large', 'full']);
234+
235+
/**
236+
* Page Region Schema
237+
*/
238+
export const PageRegionSchema = z.object({
239+
name: z.string().describe('Region name (e.g. "sidebar", "main", "header")'),
240+
type: z.enum(['header', 'sidebar', 'main', 'footer', 'aside']).optional().describe('Semantic region type'),
241+
width: z.union([PageRegionWidthSchema, z.string()]).optional().describe('Region width'),
242+
components: z.array(SchemaNodeSchema).describe('Components in this region'),
243+
className: z.string().optional().describe('CSS class overrides'),
244+
});
245+
246+
/**
247+
* Page Variable Schema
248+
*/
249+
export const PageVariableSchema = z.object({
250+
name: z.string().describe('Variable name'),
251+
type: z.enum(['string', 'number', 'boolean', 'object', 'array']).optional().default('string').describe('Variable type'),
252+
defaultValue: z.any().optional().describe('Default value'),
253+
});
254+
255+
/**
256+
* Page Type Schema
257+
*/
258+
export const PageTypeSchema = z.enum(['record', 'home', 'app', 'utility']);
259+
230260
/**
231261
* Page Schema - Top-level page layout
262+
* Aligned with @objectstack/spec PageSchema
232263
*/
233264
export const PageSchema = BaseSchema.extend({
234265
type: z.literal('page'),
235266
title: z.string().optional().describe('Page title'),
236267
icon: z.string().optional().describe('Page icon (Lucide icon name)'),
237268
description: z.string().optional().describe('Page description'),
269+
pageType: PageTypeSchema.optional().describe('Page type (record, home, app, utility)'),
270+
object: z.string().optional().describe('Bound object name (for record pages)'),
271+
template: z.string().optional().default('default').describe('Layout template name'),
272+
variables: z.array(PageVariableSchema).optional().describe('Local page state variables'),
273+
regions: z.array(PageRegionSchema).optional().describe('Page layout regions'),
238274
body: z.array(SchemaNodeSchema).optional().describe('Main content array'),
239275
children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Alternative content prop'),
276+
isDefault: z.boolean().optional().default(false).describe('Whether this is the default page'),
277+
assignedProfiles: z.array(z.string()).optional().describe('Profiles that can access this page'),
240278
});
241279

242280
/**

0 commit comments

Comments
 (0)