diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx index 9368bc3dd..e0f3d7c92 100644 --- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx @@ -45,7 +45,7 @@ vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => nul vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null })); import { useConsoleActionRuntime, ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime'; -import { useAction } from '@object-ui/react'; +import { useAction, usePageVariables, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react'; beforeEach(() => { authFetchSpy.mockReset(); @@ -325,3 +325,113 @@ describe('ConsoleActionRuntimeProvider — page-level action execution', () => { await waitFor(() => expect(onRefresh).toHaveBeenCalled()); }); }); + +// --------------------------------------------------------------------------- +// Gap 2 — page-variable → submit bridge. apiHandler resolves `{{page.}}` +// tokens in the request body against the live page-variable snapshot that +// PageVariableActionBridge publishes into the action context. This is the +// data-entry half of SDUI pages: an input writes a page variable, a submit +// button posts it. +// --------------------------------------------------------------------------- +describe('apiHandler — page-variable submit bridge', () => { + it('resolves {{page.}} tokens in params from context.pageVariables (type-preserving)', async () => { + authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }); + const { result } = renderHook(() => + useConsoleActionRuntime({ dataSource: {}, objects: [] }), + ); + + await act(async () => { + await result.current.apiHandler( + { + type: 'api', + name: 'onboard', + target: '/api/v1/cloud/onboarding/complete', + params: { + workspace_name: '{{page.workspaceName}}', + seats: '{{page.seats}}', + label: 'ws-{{page.subdomain}}', + }, + } as any, + { pageVariables: { workspaceName: 'Acme', seats: 5, subdomain: 'acme' } } as any, + ); + }); + + const body = JSON.parse(authFetchSpy.mock.calls[0][1].body); + expect(body.workspace_name).toBe('Acme'); + expect(body.seats).toBe(5); // whole-value token preserves the number type + expect(body.label).toBe('ws-acme'); // embedded token is string-interpolated + }); + + it('resolves {{page.}} tokens in bodyExtra as well', async () => { + authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({}) }); + const { result } = renderHook(() => + useConsoleActionRuntime({ dataSource: {}, objects: [] }), + ); + + await act(async () => { + await result.current.apiHandler( + { type: 'api', name: 'x', target: '/api/v1/x', bodyExtra: { src: '{{page.subdomain}}' } } as any, + { pageVariables: { subdomain: 'acme' } } as any, + ); + }); + + expect(JSON.parse(authFetchSpy.mock.calls[0][1].body).src).toBe('acme'); + }); + + it('passes tokens through verbatim when no pageVariables context is present (back-compat)', async () => { + authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({}) }); + const { result } = renderHook(() => + useConsoleActionRuntime({ dataSource: {}, objects: [] }), + ); + + await act(async () => { + await result.current.apiHandler( + { type: 'api', name: 'x', target: '/api/v1/x', params: { a: '{{page.missing}}' } } as any, + ); + }); + + expect(JSON.parse(authFetchSpy.mock.calls[0][1].body).a).toBe('{{page.missing}}'); + }); +}); + +describe('PageVariableActionBridge — end-to-end submit loop', () => { + function FormProbe() { + const { setVariable } = usePageVariables(); + const { execute } = useAction(); + return ( + <> + + + + ); + } + + it('input → page variable → submit posts the resolved value', async () => { + authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({}) }); + render( + + + + + + , + ); + + fireEvent.click(screen.getByText('type')); // writes page variable → bridge publishes snapshot + fireEvent.click(screen.getByText('submit')); // executes api action → apiHandler resolves token + + await waitFor(() => expect(authFetchSpy).toHaveBeenCalled()); + expect(JSON.parse(authFetchSpy.mock.calls[0][1].body).workspace_name).toBe('Acme'); + }); +}); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 7636f66c0..404872cfc 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -44,6 +44,7 @@ import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDi import { ActionResultDialog, type ResultDialogState } from '../views/ActionResultDialog'; import { FlowRunner, type ScreenFlowState } from '../views/FlowRunner'; import { resolveActionParams } from '../utils/resolveActionParams'; +import { resolvePageVarTokens } from '../utils/resolvePageVarTokens'; const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false }; @@ -197,7 +198,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // Authenticated fetch for direct backend calls. Declared before apiHandler. const authFetch = useMemo(() => createAuthenticatedFetch(), []); - const apiHandler = useCallback(async (action: ActionDef): Promise => { + const apiHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise => { try { const target = action.target || action.name; const params = action.params || {}; @@ -214,6 +215,13 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const rowRecord = rawParams._rowRecord as Record | undefined; delete rawParams._rowRecord; + // Resolve `{{page.}}` tokens against the live page-variable snapshot + // (published into the action context by PageVariableActionBridge). This is + // what lets a pure-SDUI form submit the values its inputs wrote into page + // variables; whole-value tokens preserve type. See resolvePageVarTokens. + const pageVars = (context?.pageVariables ?? undefined) as Record | undefined; + const resolvedParams = resolvePageVarTokens(rawParams, pageVars); + // Interpolate `{field}` tokens in the target URL from the row record. let resolvedTarget = targetStr; if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) { @@ -227,7 +235,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const wrap = action.bodyShape && typeof action.bodyShape === 'object' && action.bodyShape.wrap ? action.bodyShape.wrap : undefined; - const body: Record = wrap ? { [wrap]: rawParams } : { ...rawParams }; + const body: Record = wrap ? { [wrap]: resolvedParams } : { ...resolvedParams }; if (rowRecord && action.recordIdParam) { const rowField = action.recordIdField || 'id'; @@ -241,7 +249,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons } if (action.bodyExtra && typeof action.bodyExtra === 'object') { - Object.assign(body, action.bodyExtra); + Object.assign(body, resolvePageVarTokens(action.bodyExtra, pageVars)); } const method = (action.method || 'POST').toUpperCase(); diff --git a/packages/app-shell/src/utils/__tests__/resolvePageVarTokens.test.ts b/packages/app-shell/src/utils/__tests__/resolvePageVarTokens.test.ts new file mode 100644 index 000000000..4886a76e0 --- /dev/null +++ b/packages/app-shell/src/utils/__tests__/resolvePageVarTokens.test.ts @@ -0,0 +1,83 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { resolvePageVarTokens } from '../resolvePageVarTokens'; + +const vars = { + workspaceName: 'Acme', + seats: 5, + active: true, + subdomain: 'acme', + owner: { id: 'u_1', name: 'Ada' }, + tags: ['a', 'b'], +}; + +describe('resolvePageVarTokens', () => { + it('returns the value unchanged when there is no snapshot', () => { + const input = { a: '{{page.workspaceName}}' }; + expect(resolvePageVarTokens(input, undefined)).toEqual(input); + expect(resolvePageVarTokens(input, null)).toEqual(input); + }); + + it('replaces a whole-value token with the raw typed value (type-preserving)', () => { + expect(resolvePageVarTokens('{{page.workspaceName}}', vars)).toBe('Acme'); + expect(resolvePageVarTokens('{{page.seats}}', vars)).toBe(5); + expect(resolvePageVarTokens('{{page.active}}', vars)).toBe(true); + expect(resolvePageVarTokens('{{ page.seats }}', vars)).toBe(5); // tolerant of inner spaces + }); + + it('preserves object / array values for whole-value tokens', () => { + expect(resolvePageVarTokens('{{page.owner}}', vars)).toEqual({ id: 'u_1', name: 'Ada' }); + expect(resolvePageVarTokens('{{page.tags}}', vars)).toEqual(['a', 'b']); + }); + + it('string-interpolates embedded tokens', () => { + expect(resolvePageVarTokens('/orgs/{{page.subdomain}}/setup', vars)).toBe('/orgs/acme/setup'); + expect(resolvePageVarTokens('{{page.workspaceName}} ({{page.seats}})', vars)).toBe('Acme (5)'); + }); + + it('resolves dotted paths into object variables', () => { + expect(resolvePageVarTokens('{{page.owner.name}}', vars)).toBe('Ada'); + expect(resolvePageVarTokens('owner: {{page.owner.id}}', vars)).toBe('owner: u_1'); + }); + + it('resolves a missing whole-value token to an empty string and drops missing embedded tokens', () => { + expect(resolvePageVarTokens('{{page.nope}}', vars)).toBe(''); + expect(resolvePageVarTokens('x={{page.nope}}', vars)).toBe('x='); + }); + + it('walks nested objects and arrays', () => { + const input = { + workspace_name: '{{page.workspaceName}}', + seats: '{{page.seats}}', + nested: { slug: '{{page.subdomain}}', list: ['{{page.workspaceName}}', 'static'] }, + untouched: 7, + }; + expect(resolvePageVarTokens(input, vars)).toEqual({ + workspace_name: 'Acme', + seats: 5, + nested: { slug: 'acme', list: ['Acme', 'static'] }, + untouched: 7, + }); + }); + + it('leaves non-token strings and non-string leaves untouched', () => { + expect(resolvePageVarTokens('plain', vars)).toBe('plain'); + expect(resolvePageVarTokens('{single}', vars)).toBe('{single}'); // not a {{page}} token + expect(resolvePageVarTokens(42, vars)).toBe(42); + expect(resolvePageVarTokens(null, vars)).toBe(null); + }); + + it('does not mutate the input object', () => { + const input = { a: '{{page.workspaceName}}' }; + const out = resolvePageVarTokens(input, vars); + expect(input.a).toBe('{{page.workspaceName}}'); + expect(out).not.toBe(input); + }); +}); diff --git a/packages/app-shell/src/utils/resolvePageVarTokens.ts b/packages/app-shell/src/utils/resolvePageVarTokens.ts new file mode 100644 index 000000000..430c03de4 --- /dev/null +++ b/packages/app-shell/src/utils/resolvePageVarTokens.ts @@ -0,0 +1,77 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * resolvePageVarTokens — resolve `{{page.}}` tokens against a page-variable + * snapshot. The data-entry bridge for SDUI forms: an interactive input + * (`element:text_input`, `element:record_picker`) writes a page variable; a + * submit action references it in its params/body as `{{page.}}`; this + * resolves those tokens against the live snapshot — published into the action + * context by `PageVariableActionBridge` — just before the request body is built. + * + * - A WHOLE-VALUE token (`"{{page.amount}}"`) is replaced by the variable's RAW + * value, preserving its type — a number stays a number, an object stays an + * object — so numeric/boolean/array form fields submit with the right JSON + * type rather than being stringified. + * - An EMBEDDED token (`"/orgs/{{page.slug}}/setup"`) is string-interpolated. + * - Resolution walks nested objects/arrays. A whole-value miss resolves to '' + * (kept as a present-but-empty field); an embedded miss drops to ''. + * + * Distinct from the single-brace `{field}` row-record interpolation used in API + * target URLs (different brace count, different source) so the two never collide. + */ + +const WHOLE_RE = /^\s*\{\{\s*page\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}\}\s*$/; + +function lookup(path: string, vars: Record): unknown { + let node: any = vars; + for (const seg of path.split('.')) { + if (node == null) return undefined; + node = node[seg]; + } + return node; +} + +function resolveString(str: string, vars: Record): unknown { + if (!str.includes('{{')) return str; + const whole = str.match(WHOLE_RE); + if (whole) { + const v = lookup(whole[1], vars); + return v === undefined ? '' : v; + } + // Fresh global regex per call — avoids shared `lastIndex` state. + return str.replace( + /\{\{\s*page\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}\}/g, + (_m, path) => { + const v = lookup(path, vars); + return v == null ? '' : String(v); + }, + ); +} + +function walk(value: any, vars: Record): any { + if (typeof value === 'string') return resolveString(value, vars); + if (Array.isArray(value)) return value.map((v) => walk(v, vars)); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = walk(v, vars); + return out; + } + return value; +} + +/** + * Deep-resolve every `{{page.}}` token in `value` against `pageVariables`. + * Returns `value` unchanged when there is no snapshot. Non-string leaves pass + * through untouched; the input is never mutated (objects/arrays are copied). + */ +export function resolvePageVarTokens( + value: T, + pageVariables?: Record | null, +): T { + if (!pageVariables) return value; + return walk(value, pageVariables) as T; +} diff --git a/packages/components/src/__tests__/text-input.test.tsx b/packages/components/src/__tests__/text-input.test.tsx new file mode 100644 index 000000000..830bb9cc1 --- /dev/null +++ b/packages/components/src/__tests__/text-input.test.tsx @@ -0,0 +1,105 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * element:text_input — the free-text data-entry element. Proves it: + * 1. is registered in the ComponentRegistry; + * 2. writes the typed value into the page variable bound by `source`; + * 3. honors `inputType` (and coerces a number input to a numeric value); + * 4. seeds `defaultValue` into an empty bound variable on mount; + * 5. is safe to drop outside a Page (uncontrolled, never throws). + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, PageVariablesProvider, usePageVariables } from '@object-ui/react'; + +beforeAll(async () => { + await import('../renderers'); +}, 30000); + +// Reads a page variable back out so a test can assert what the input wrote. +function VarProbe({ name }: { name: string }) { + const { variables } = usePageVariables(); + return {JSON.stringify(variables[name])}; +} + +describe('element:text_input', () => { + it('is registered', () => { + expect(ComponentRegistry.get('element:text_input')).toBeTruthy(); + }); + + it('renders a labeled input and writes typed text into the bound page variable', () => { + const { container, getByText, getByTestId } = render( + + + + , + ); + + expect(getByText('Workspace')).toBeTruthy(); + const input = container.querySelector('input')!; + expect(input).toBeTruthy(); + expect(input.getAttribute('placeholder')).toBe('acme'); + + fireEvent.change(input, { target: { value: 'Acme Inc' } }); + expect(getByTestId('var-workspace').textContent).toBe(JSON.stringify('Acme Inc')); + }); + + it('applies inputType to the native input element', () => { + const { container } = render( + + + , + ); + expect(container.querySelector('input')!.getAttribute('type')).toBe('email'); + }); + + it('coerces a number input to a numeric page-variable value', () => { + const { container, getByTestId } = render( + + + + , + ); + fireEvent.change(container.querySelector('input')!, { target: { value: '42' } }); + // JSON.stringify(42) === '42' (a number); a string would be '"42"'. + expect(getByTestId('var-seats').textContent).toBe('42'); + }); + + it('seeds defaultValue into an empty bound variable on mount', () => { + const { getByTestId } = render( + + + + , + ); + expect(getByTestId('var-sub').textContent).toBe(JSON.stringify('acme')); + }); + + it('renders without a binding (safe to drop outside a Page)', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('text-input')).toBeTruthy(); + }); +}); diff --git a/packages/components/src/renderers/basic/index.ts b/packages/components/src/renderers/basic/index.ts index 80f3f9257..534e3f506 100644 --- a/packages/components/src/renderers/basic/index.ts +++ b/packages/components/src/renderers/basic/index.ts @@ -20,3 +20,4 @@ import './elements'; import './metadata-viewer'; import './data-list'; import './record-picker'; +import './text-input'; diff --git a/packages/components/src/renderers/basic/text-input.tsx b/packages/components/src/renderers/basic/text-input.tsx new file mode 100644 index 000000000..16d7c3228 --- /dev/null +++ b/packages/components/src/renderers/basic/text-input.tsx @@ -0,0 +1,149 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * element:text_input — a single-line free-text input that writes the typed + * value into a page variable. The data-entry complement to + * element:record_picker (which picks an existing record): together they let a + * pure-SDUI page COLLECT input, which a submit button then posts via the action + * runtime's `{{page.}}` bridge (useConsoleActionRuntime). + * + * Config is read off `schema.properties` (`schema.props` tolerated as a legacy + * alias): + * { inputType='text', label?, placeholder?, defaultValue?, required?, + * disabled?, description? } + * + * The value is written through `usePageVariableBinding(schema.id)`: the page + * variable whose `source` equals this input's id receives every keystroke. With + * no bound variable the input is uncontrolled (still usable, just not wired) so + * it never throws outside a Page — mirroring element:record_picker. An + * `inputType='number'` coerces the written value to a Number (empty → '') so + * `page.` and any numeric submit param stay typed. + */ + +import * as React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { usePageVariableBinding } from '@object-ui/react'; +import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; +import { Input, Label } from '../../ui'; +import { cn } from '../../lib/utils'; + +function readProps>(schema: any): T { + // Per spec, element components carry their config in `schema.properties`. + // Tolerate `schema.props` (legacy alias) so JSON written either way works. + const fromProperties = (schema?.properties ?? {}) as T; + const fromProps = (schema?.props ?? {}) as T; + return { ...fromProps, ...fromProperties }; +} + +type TextInputType = 'text' | 'email' | 'number' | 'tel' | 'url' | 'password'; +const INPUT_TYPES: TextInputType[] = ['text', 'email', 'number', 'tel', 'url', 'password']; + +function ElementTextInputRenderer({ schema }: { schema: any }) { + const props = readProps<{ + inputType?: TextInputType; + label?: unknown; + placeholder?: unknown; + defaultValue?: string | number; + required?: boolean; + disabled?: boolean; + description?: unknown; + }>(schema); + + const inputType: TextInputType = INPUT_TYPES.includes(props.inputType as TextInputType) + ? (props.inputType as TextInputType) + : 'text'; + const { language } = useObjectTranslation(); + const binding = usePageVariableBinding(schema?.id); + + // Convenience seeding: when a `defaultValue` is authored on the input and the + // bound variable is still at its empty default, push it once on mount so + // `page.` (and the submit body) reflect the initial value even before + // the user types. A variable that declares its OWN defaultValue wins — we + // only seed when the variable is still empty. + const seeded = React.useRef(false); + React.useEffect(() => { + if (seeded.current) return; + seeded.current = true; + if ( + binding && + props.defaultValue !== undefined && + (binding.value == null || binding.value === '') + ) { + binding.setValue(props.defaultValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Controlled when a variable targets this input (empty string = no value), + // uncontrolled otherwise (native input manages its own state). Coerce to a + // string for the DOM element's `value`. + const current = binding?.value; + const value = binding ? (current == null ? '' : String(current)) : undefined; + + const handleChange = React.useCallback( + (e: React.ChangeEvent) => { + if (!binding) return; + const raw = e.target.value; + binding.setValue(inputType === 'number' ? (raw === '' ? '' : Number(raw)) : raw); + }, + [binding, inputType], + ); + + const label = pickLocalized(props.label, language); + const placeholder = pickLocalized(props.placeholder, language); + const description = pickLocalized(props.description, language); + + return ( +
+ {label && ( + + )} + + {description &&

{description}

} +
+ ); +} + +ComponentRegistry.register('element:text_input', ElementTextInputRenderer, { + namespace: 'element', + label: 'Text Input', + category: 'input', + inputs: [ + { name: 'label', type: 'string', label: 'Label' }, + { name: 'placeholder', type: 'string', label: 'Placeholder' }, + { + name: 'inputType', + type: 'enum', + label: 'Type', + enum: ['text', 'email', 'number', 'tel', 'url', 'password'], + defaultValue: 'text', + }, + { name: 'required', type: 'boolean', label: 'Required' }, + { name: 'disabled', type: 'boolean', label: 'Disabled' }, + { name: 'description', type: 'string', label: 'Description' }, + ], +}); + +export { ElementTextInputRenderer }; diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index c87812245..d678ebbd3 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -14,7 +14,7 @@ import React, { useMemo } from 'react'; import type { PageSchema, PageRegion, SchemaNode } from '@object-ui/types'; -import { SchemaRenderer, PageVariablesProvider } from '@object-ui/react'; +import { SchemaRenderer, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react'; import { ComponentRegistry } from '@object-ui/core'; import { cn } from '../../lib/utils'; @@ -470,6 +470,9 @@ export const PageRenderer: React.FC<{ if (schema.variables && schema.variables.length > 0) { return ( + {/* Publish the live page-variable snapshot into the action runtime so a + submit button can post `{{page.}}` values (SDUI form data-entry). */} + {pageContent} ); diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 7fdb65bc9..1e3839171 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -47,6 +47,8 @@ export interface ActionContext { data?: Record; record?: any; selectedRecords?: Record[]; + /** Live page-variable snapshot (ADR-0049), published by PageVariableActionBridge. */ + pageVariables?: Record; user?: any; [key: string]: any; } diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 25272e656..0ac91a68c 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -10,6 +10,7 @@ export * from './useExpression'; export * from './useActionRunner'; export * from './useNavigationOverlay'; export * from './usePageVariables'; +export * from './usePageVariableActionBridge'; export * from './useViewData'; export * from './useDynamicApp'; export * from './useDiscovery'; diff --git a/packages/react/src/hooks/usePageVariableActionBridge.tsx b/packages/react/src/hooks/usePageVariableActionBridge.tsx new file mode 100644 index 000000000..a4e509f8d --- /dev/null +++ b/packages/react/src/hooks/usePageVariableActionBridge.tsx @@ -0,0 +1,44 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { usePageVariables } from './usePageVariables'; +import { useAction } from '../context'; + +/** + * PageVariableActionBridge — publishes the live page-variable snapshot into the + * shared ActionRunner context as `pageVariables`, so a submit action (`api` / + * `script` / `flow`, or an `onClick` expression) can pull page-local form state + * into its request body via `{{page.}}` token interpolation (resolved in + * app-shell's `useConsoleActionRuntime`). + * + * This is the data-entry counterpart to ObjectGrid publishing `selectedRecords` + * into the same context. Page variables live BELOW the ActionProvider — they're + * mounted by `PageVariablesProvider` inside the page layout renderer, whereas + * the action runtime is mounted above it — so the runtime can't read them + * directly. The bridge carries them up via `updateContext`. + * + * Mount it once inside a `PageVariablesProvider`. Outside an `ActionProvider`, + * `useAction()` falls back to a throwaway runner, so the bridge is a safe no-op + * (never throws in standalone / test contexts). Renders nothing. + */ +export function PageVariableActionBridge(): null { + const { variables } = usePageVariables(); + const { updateContext } = useAction(); + + React.useEffect(() => { + updateContext({ pageVariables: variables }); + return () => { + updateContext({ pageVariables: {} }); + }; + }, [variables, updateContext]); + + return null; +} + +PageVariableActionBridge.displayName = 'PageVariableActionBridge'; diff --git a/packages/types/src/ui-action.ts b/packages/types/src/ui-action.ts index 90730d498..204baa0d8 100644 --- a/packages/types/src/ui-action.ts +++ b/packages/types/src/ui-action.ts @@ -280,6 +280,10 @@ export interface ActionContext { /** Selected records (for list actions) */ selectedRecords?: Record[]; + + /** Live page-variable snapshot (ADR-0049) published by PageVariableActionBridge — + * lets a submit action read page-local form state via `{{page.}}` tokens. */ + pageVariables?: Record; /** Current user */ user?: Record;