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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -325,3 +325,113 @@ describe('ConsoleActionRuntimeProvider — page-level action execution', () => {
await waitFor(() => expect(onRefresh).toHaveBeenCalled());
});
});

// ---------------------------------------------------------------------------
// Gap 2 — page-variable → submit bridge. apiHandler resolves `{{page.<var>}}`
// 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.<var>}} 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.<var>}} 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 (
<>
<button onClick={() => setVariable('workspaceName', 'Acme')}>type</button>
<button
onClick={() =>
void execute({
type: 'api',
name: 'onboard',
target: '/api/v1/cloud/onboarding/complete',
params: { workspace_name: '{{page.workspaceName}}' },
} as any)
}
>
submit
</button>
</>
);
}

it('input → page variable → submit posts the resolved value', async () => {
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({}) });
render(
<ConsoleActionRuntimeProvider dataSource={{}} objects={[]}>
<PageVariablesProvider definitions={[{ name: 'workspaceName', type: 'string', source: 'ws' }]}>
<PageVariableActionBridge />
<FormProbe />
</PageVariablesProvider>
</ConsoleActionRuntimeProvider>,
);

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');
});
});
14 changes: 11 additions & 3 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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<ActionResult> => {
const apiHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise<ActionResult> => {
try {
const target = action.target || action.name;
const params = action.params || {};
Expand All @@ -214,6 +215,13 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
const rowRecord = rawParams._rowRecord as Record<string, any> | undefined;
delete rawParams._rowRecord;

// Resolve `{{page.<var>}}` 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<string, any> | 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)) {
Expand All @@ -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<string, any> = wrap ? { [wrap]: rawParams } : { ...rawParams };
const body: Record<string, any> = wrap ? { [wrap]: resolvedParams } : { ...resolvedParams };

if (rowRecord && action.recordIdParam) {
const rowField = action.recordIdField || 'id';
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
77 changes: 77 additions & 0 deletions packages/app-shell/src/utils/resolvePageVarTokens.ts
Original file line number Diff line number Diff line change
@@ -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.<path>}}` 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.<var>}}`; 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<string, any>): 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<string, any>): 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<string, any>): 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<string, any> = {};
for (const [k, v] of Object.entries(value)) out[k] = walk(v, vars);
return out;
}
return value;
}

/**
* Deep-resolve every `{{page.<var>}}` 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<T>(
value: T,
pageVariables?: Record<string, any> | null,
): T {
if (!pageVariables) return value;
return walk(value, pageVariables) as T;
}
Loading
Loading