Skip to content

Commit e98dfbb

Browse files
authored
feat(sdui): render free-text form inputs + bridge page variables into submit actions (#1995)
Adds element:text_input (writes a page variable) + the page-variable→submit bridge ({{page.<var>}} resolved in the console action runtime). Pairs with framework#2321 (spec + showcase demo).
1 parent 6028192 commit e98dfbb

12 files changed

Lines changed: 592 additions & 5 deletions

File tree

packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => nul
4545
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));
4646

4747
import { useConsoleActionRuntime, ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime';
48-
import { useAction } from '@object-ui/react';
48+
import { useAction, usePageVariables, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react';
4949

5050
beforeEach(() => {
5151
authFetchSpy.mockReset();
@@ -325,3 +325,113 @@ describe('ConsoleActionRuntimeProvider — page-level action execution', () => {
325325
await waitFor(() => expect(onRefresh).toHaveBeenCalled());
326326
});
327327
});
328+
329+
// ---------------------------------------------------------------------------
330+
// Gap 2 — page-variable → submit bridge. apiHandler resolves `{{page.<var>}}`
331+
// tokens in the request body against the live page-variable snapshot that
332+
// PageVariableActionBridge publishes into the action context. This is the
333+
// data-entry half of SDUI pages: an input writes a page variable, a submit
334+
// button posts it.
335+
// ---------------------------------------------------------------------------
336+
describe('apiHandler — page-variable submit bridge', () => {
337+
it('resolves {{page.<var>}} tokens in params from context.pageVariables (type-preserving)', async () => {
338+
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({ ok: true }) });
339+
const { result } = renderHook(() =>
340+
useConsoleActionRuntime({ dataSource: {}, objects: [] }),
341+
);
342+
343+
await act(async () => {
344+
await result.current.apiHandler(
345+
{
346+
type: 'api',
347+
name: 'onboard',
348+
target: '/api/v1/cloud/onboarding/complete',
349+
params: {
350+
workspace_name: '{{page.workspaceName}}',
351+
seats: '{{page.seats}}',
352+
label: 'ws-{{page.subdomain}}',
353+
},
354+
} as any,
355+
{ pageVariables: { workspaceName: 'Acme', seats: 5, subdomain: 'acme' } } as any,
356+
);
357+
});
358+
359+
const body = JSON.parse(authFetchSpy.mock.calls[0][1].body);
360+
expect(body.workspace_name).toBe('Acme');
361+
expect(body.seats).toBe(5); // whole-value token preserves the number type
362+
expect(body.label).toBe('ws-acme'); // embedded token is string-interpolated
363+
});
364+
365+
it('resolves {{page.<var>}} tokens in bodyExtra as well', async () => {
366+
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({}) });
367+
const { result } = renderHook(() =>
368+
useConsoleActionRuntime({ dataSource: {}, objects: [] }),
369+
);
370+
371+
await act(async () => {
372+
await result.current.apiHandler(
373+
{ type: 'api', name: 'x', target: '/api/v1/x', bodyExtra: { src: '{{page.subdomain}}' } } as any,
374+
{ pageVariables: { subdomain: 'acme' } } as any,
375+
);
376+
});
377+
378+
expect(JSON.parse(authFetchSpy.mock.calls[0][1].body).src).toBe('acme');
379+
});
380+
381+
it('passes tokens through verbatim when no pageVariables context is present (back-compat)', async () => {
382+
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({}) });
383+
const { result } = renderHook(() =>
384+
useConsoleActionRuntime({ dataSource: {}, objects: [] }),
385+
);
386+
387+
await act(async () => {
388+
await result.current.apiHandler(
389+
{ type: 'api', name: 'x', target: '/api/v1/x', params: { a: '{{page.missing}}' } } as any,
390+
);
391+
});
392+
393+
expect(JSON.parse(authFetchSpy.mock.calls[0][1].body).a).toBe('{{page.missing}}');
394+
});
395+
});
396+
397+
describe('PageVariableActionBridge — end-to-end submit loop', () => {
398+
function FormProbe() {
399+
const { setVariable } = usePageVariables();
400+
const { execute } = useAction();
401+
return (
402+
<>
403+
<button onClick={() => setVariable('workspaceName', 'Acme')}>type</button>
404+
<button
405+
onClick={() =>
406+
void execute({
407+
type: 'api',
408+
name: 'onboard',
409+
target: '/api/v1/cloud/onboarding/complete',
410+
params: { workspace_name: '{{page.workspaceName}}' },
411+
} as any)
412+
}
413+
>
414+
submit
415+
</button>
416+
</>
417+
);
418+
}
419+
420+
it('input → page variable → submit posts the resolved value', async () => {
421+
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({}) });
422+
render(
423+
<ConsoleActionRuntimeProvider dataSource={{}} objects={[]}>
424+
<PageVariablesProvider definitions={[{ name: 'workspaceName', type: 'string', source: 'ws' }]}>
425+
<PageVariableActionBridge />
426+
<FormProbe />
427+
</PageVariablesProvider>
428+
</ConsoleActionRuntimeProvider>,
429+
);
430+
431+
fireEvent.click(screen.getByText('type')); // writes page variable → bridge publishes snapshot
432+
fireEvent.click(screen.getByText('submit')); // executes api action → apiHandler resolves token
433+
434+
await waitFor(() => expect(authFetchSpy).toHaveBeenCalled());
435+
expect(JSON.parse(authFetchSpy.mock.calls[0][1].body).workspace_name).toBe('Acme');
436+
});
437+
});

packages/app-shell/src/hooks/useConsoleActionRuntime.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDi
4444
import { ActionResultDialog, type ResultDialogState } from '../views/ActionResultDialog';
4545
import { FlowRunner, type ScreenFlowState } from '../views/FlowRunner';
4646
import { resolveActionParams } from '../utils/resolveActionParams';
47+
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens';
4748

4849
const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false };
4950

@@ -197,7 +198,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
197198
// Authenticated fetch for direct backend calls. Declared before apiHandler.
198199
const authFetch = useMemo(() => createAuthenticatedFetch(), []);
199200

200-
const apiHandler = useCallback(async (action: ActionDef): Promise<ActionResult> => {
201+
const apiHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise<ActionResult> => {
201202
try {
202203
const target = action.target || action.name;
203204
const params = action.params || {};
@@ -214,6 +215,13 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
214215
const rowRecord = rawParams._rowRecord as Record<string, any> | undefined;
215216
delete rawParams._rowRecord;
216217

218+
// Resolve `{{page.<var>}}` tokens against the live page-variable snapshot
219+
// (published into the action context by PageVariableActionBridge). This is
220+
// what lets a pure-SDUI form submit the values its inputs wrote into page
221+
// variables; whole-value tokens preserve type. See resolvePageVarTokens.
222+
const pageVars = (context?.pageVariables ?? undefined) as Record<string, any> | undefined;
223+
const resolvedParams = resolvePageVarTokens(rawParams, pageVars);
224+
217225
// Interpolate `{field}` tokens in the target URL from the row record.
218226
let resolvedTarget = targetStr;
219227
if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) {
@@ -227,7 +235,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
227235
const wrap = action.bodyShape && typeof action.bodyShape === 'object' && action.bodyShape.wrap
228236
? action.bodyShape.wrap
229237
: undefined;
230-
const body: Record<string, any> = wrap ? { [wrap]: rawParams } : { ...rawParams };
238+
const body: Record<string, any> = wrap ? { [wrap]: resolvedParams } : { ...resolvedParams };
231239

232240
if (rowRecord && action.recordIdParam) {
233241
const rowField = action.recordIdField || 'id';
@@ -241,7 +249,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
241249
}
242250

243251
if (action.bodyExtra && typeof action.bodyExtra === 'object') {
244-
Object.assign(body, action.bodyExtra);
252+
Object.assign(body, resolvePageVarTokens(action.bodyExtra, pageVars));
245253
}
246254

247255
const method = (action.method || 'POST').toUpperCase();
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 { describe, it, expect } from 'vitest';
10+
import { resolvePageVarTokens } from '../resolvePageVarTokens';
11+
12+
const vars = {
13+
workspaceName: 'Acme',
14+
seats: 5,
15+
active: true,
16+
subdomain: 'acme',
17+
owner: { id: 'u_1', name: 'Ada' },
18+
tags: ['a', 'b'],
19+
};
20+
21+
describe('resolvePageVarTokens', () => {
22+
it('returns the value unchanged when there is no snapshot', () => {
23+
const input = { a: '{{page.workspaceName}}' };
24+
expect(resolvePageVarTokens(input, undefined)).toEqual(input);
25+
expect(resolvePageVarTokens(input, null)).toEqual(input);
26+
});
27+
28+
it('replaces a whole-value token with the raw typed value (type-preserving)', () => {
29+
expect(resolvePageVarTokens('{{page.workspaceName}}', vars)).toBe('Acme');
30+
expect(resolvePageVarTokens('{{page.seats}}', vars)).toBe(5);
31+
expect(resolvePageVarTokens('{{page.active}}', vars)).toBe(true);
32+
expect(resolvePageVarTokens('{{ page.seats }}', vars)).toBe(5); // tolerant of inner spaces
33+
});
34+
35+
it('preserves object / array values for whole-value tokens', () => {
36+
expect(resolvePageVarTokens('{{page.owner}}', vars)).toEqual({ id: 'u_1', name: 'Ada' });
37+
expect(resolvePageVarTokens('{{page.tags}}', vars)).toEqual(['a', 'b']);
38+
});
39+
40+
it('string-interpolates embedded tokens', () => {
41+
expect(resolvePageVarTokens('/orgs/{{page.subdomain}}/setup', vars)).toBe('/orgs/acme/setup');
42+
expect(resolvePageVarTokens('{{page.workspaceName}} ({{page.seats}})', vars)).toBe('Acme (5)');
43+
});
44+
45+
it('resolves dotted paths into object variables', () => {
46+
expect(resolvePageVarTokens('{{page.owner.name}}', vars)).toBe('Ada');
47+
expect(resolvePageVarTokens('owner: {{page.owner.id}}', vars)).toBe('owner: u_1');
48+
});
49+
50+
it('resolves a missing whole-value token to an empty string and drops missing embedded tokens', () => {
51+
expect(resolvePageVarTokens('{{page.nope}}', vars)).toBe('');
52+
expect(resolvePageVarTokens('x={{page.nope}}', vars)).toBe('x=');
53+
});
54+
55+
it('walks nested objects and arrays', () => {
56+
const input = {
57+
workspace_name: '{{page.workspaceName}}',
58+
seats: '{{page.seats}}',
59+
nested: { slug: '{{page.subdomain}}', list: ['{{page.workspaceName}}', 'static'] },
60+
untouched: 7,
61+
};
62+
expect(resolvePageVarTokens(input, vars)).toEqual({
63+
workspace_name: 'Acme',
64+
seats: 5,
65+
nested: { slug: 'acme', list: ['Acme', 'static'] },
66+
untouched: 7,
67+
});
68+
});
69+
70+
it('leaves non-token strings and non-string leaves untouched', () => {
71+
expect(resolvePageVarTokens('plain', vars)).toBe('plain');
72+
expect(resolvePageVarTokens('{single}', vars)).toBe('{single}'); // not a {{page}} token
73+
expect(resolvePageVarTokens(42, vars)).toBe(42);
74+
expect(resolvePageVarTokens(null, vars)).toBe(null);
75+
});
76+
77+
it('does not mutate the input object', () => {
78+
const input = { a: '{{page.workspaceName}}' };
79+
const out = resolvePageVarTokens(input, vars);
80+
expect(input.a).toBe('{{page.workspaceName}}');
81+
expect(out).not.toBe(input);
82+
});
83+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
* resolvePageVarTokens — resolve `{{page.<path>}}` tokens against a page-variable
9+
* snapshot. The data-entry bridge for SDUI forms: an interactive input
10+
* (`element:text_input`, `element:record_picker`) writes a page variable; a
11+
* submit action references it in its params/body as `{{page.<var>}}`; this
12+
* resolves those tokens against the live snapshot — published into the action
13+
* context by `PageVariableActionBridge` — just before the request body is built.
14+
*
15+
* - A WHOLE-VALUE token (`"{{page.amount}}"`) is replaced by the variable's RAW
16+
* value, preserving its type — a number stays a number, an object stays an
17+
* object — so numeric/boolean/array form fields submit with the right JSON
18+
* type rather than being stringified.
19+
* - An EMBEDDED token (`"/orgs/{{page.slug}}/setup"`) is string-interpolated.
20+
* - Resolution walks nested objects/arrays. A whole-value miss resolves to ''
21+
* (kept as a present-but-empty field); an embedded miss drops to ''.
22+
*
23+
* Distinct from the single-brace `{field}` row-record interpolation used in API
24+
* target URLs (different brace count, different source) so the two never collide.
25+
*/
26+
27+
const WHOLE_RE = /^\s*\{\{\s*page\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}\}\s*$/;
28+
29+
function lookup(path: string, vars: Record<string, any>): unknown {
30+
let node: any = vars;
31+
for (const seg of path.split('.')) {
32+
if (node == null) return undefined;
33+
node = node[seg];
34+
}
35+
return node;
36+
}
37+
38+
function resolveString(str: string, vars: Record<string, any>): unknown {
39+
if (!str.includes('{{')) return str;
40+
const whole = str.match(WHOLE_RE);
41+
if (whole) {
42+
const v = lookup(whole[1], vars);
43+
return v === undefined ? '' : v;
44+
}
45+
// Fresh global regex per call — avoids shared `lastIndex` state.
46+
return str.replace(
47+
/\{\{\s*page\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}\}/g,
48+
(_m, path) => {
49+
const v = lookup(path, vars);
50+
return v == null ? '' : String(v);
51+
},
52+
);
53+
}
54+
55+
function walk(value: any, vars: Record<string, any>): any {
56+
if (typeof value === 'string') return resolveString(value, vars);
57+
if (Array.isArray(value)) return value.map((v) => walk(v, vars));
58+
if (value && typeof value === 'object') {
59+
const out: Record<string, any> = {};
60+
for (const [k, v] of Object.entries(value)) out[k] = walk(v, vars);
61+
return out;
62+
}
63+
return value;
64+
}
65+
66+
/**
67+
* Deep-resolve every `{{page.<var>}}` token in `value` against `pageVariables`.
68+
* Returns `value` unchanged when there is no snapshot. Non-string leaves pass
69+
* through untouched; the input is never mutated (objects/arrays are copied).
70+
*/
71+
export function resolvePageVarTokens<T>(
72+
value: T,
73+
pageVariables?: Record<string, any> | null,
74+
): T {
75+
if (!pageVariables) return value;
76+
return walk(value, pageVariables) as T;
77+
}

0 commit comments

Comments
 (0)