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
12 changes: 12 additions & 0 deletions .changeset/7297-record-id-filter-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/core': minor
'@object-ui/react': minor
'@object-ui/plugin-grid': patch
'@object-ui/plugin-view': patch
---

A filter on a record page can now be scoped to the record the page shows (objectui#7297). Write `{record_id}` as a filter value, for example `{ "assignee": "{record_id}" }` on an `element:number` counting tasks, and on a `type: 'record'` page it resolves to the id of the record in view: on one person's page the number is that person's count, and on the next person's page it is theirs. It works in a component's own `filter` and in its component-level `dataSource.filter`, on every data component that resolves its filter through `@object-ui/react`'s `useFilterScope()`. The token is declared by `@objectstack/spec` 17.5.0 (`RECORD_CONTEXT_TOKENS`, objectstack-ai/objectstack#20003).

The id comes from the record page's mounted record context, never from a URL parameter or a page variable. `@object-ui/react`'s `useFilterScope()` adds it to the scope it returns (as the new optional `recordId` member of `@object-ui/core`'s `FilterTokenScope`) whenever a `RecordContextProvider` is mounted above the component; `FilterScopeProvider` is unchanged and still carries only the session values. The held filters in `useResolvedFilter`, `object-grid` and `object-view` resolve again when the record changes, so moving to another record queries again without a remount.

Anywhere with no record in context (a list view, a dashboard, a report, or a page that is not a record page) `{record_id}` is refused by name: `resolveContextTokens` reports it through `onUnresolved` (a console warning by default), the way an unresolved `{current_user_id}` is reported, and leaves it as written. It never becomes `null` and its condition is never dropped, so a number can never silently count every record; the ObjectStack server refuses the leftover token by name (`FILTER_TOKEN_UNRESOLVED`). Like the session tokens, `{record_id}` only scopes what a component shows. It is not access control: which rows a user may read is still decided by the server's row-level security.
27 changes: 27 additions & 0 deletions content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,33 @@ has no ordering and a capped count would be a wrong number. An `element:number`
that sets `aggregate` but names no object in either place (no `properties.object`,
no `dataSource.object`) shows a short "no object named" notice instead of a count.

#### Scoping a filter to the record in view: `{record_id}`

On a `type: 'record'` page, a filter value can name the record the page shows
with `{record_id}`, in a component's own filter and in its `dataSource.filter`
alike. On a person's record page this counts that person's open tasks, and the
next person's page counts theirs:

```json
{
"type": "element:number",
"dataSource": {
"object": "task",
"filter": [{ "field": "assignee", "operator": "equals", "value": "{record_id}" }]
},
"properties": { "aggregate": "count" }
}
```

The id is the page's mounted record context, never a URL parameter or a page
variable. Anywhere with no record in context (a list view, a dashboard, a report,
a page that is not a record page) the token is refused by name: the renderer
warns, the value is left as written rather than dropped or blanked, and the
ObjectStack server refuses the query (`FILTER_TOKEN_UNRESOLVED`), so the number
never silently becomes a count over every record. `{record_id}` scopes what a
component *shows*; it is not access control, which stays with the server's
row-level security (objectui#7297).

On `record:related_list` and `record:line_items` the composed filter is
AND-combined with the parent relationship condition, never substituted for it: a
child panel is always scoped to the record it appears on, and an *additional*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* 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:number` counts rows scoped to the RECORD IN VIEW (objectui#7297).
*
* The card, measured on a person's record page: the only filter an author
* could write counted the whole organisation's tasks, under that person's name.
* `@objectstack/spec` 17.5.0 declares `{record_id}` (`RECORD_CONTEXT_TOKENS`,
* objectstack-ai/objectstack#20003); `useFilterScope()` now hands the resolver
* the id of the mounted record (`RecordContextProvider`, the provider whose row
* `visibleWhen` binds as `record`), and the metric resolves its filter through
* the hook it already used for the session tokens. So:
*
* - on a record page the count follows the record: two records, two counts,
* with no remount between them;
* - the component-level `dataSource.filter` takes the token too, through the
* same resolution;
* - with no record in context the token is refused by name (the
* `onUnresolved` channel, defaulting to `console.warn`) and left as written,
* so the query can only be refused or match nothing, never count everybody;
* the server's own refusal (`FILTER_TOKEN_UNRESOLVED` / 400) is shown where
* an unresolved `{current_user_id}`'s is, instead of a number.
*
* Driven through the real `SchemaRenderer` and this package's registrations.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, screen, waitFor } from '@testing-library/react';
import { AdapterCtx, FilterScopeProvider, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

/** How many open tasks each person has, keyed by the assignee comparand. */
const TASKS_BY_ASSIGNEE: Record<string, number> = { rec_ada: 3, rec_grace: 5 };

/** The comparand a filter carries for `assignee`, in either filter shape. */
function assigneeOf(filter: unknown): unknown {
const text = JSON.stringify(filter ?? null);
const hit = Object.keys(TASKS_BY_ASSIGNEE).find((id) => text.includes(`"${id}"`));
if (hit) return hit;
return text.includes('{record_id}') ? '{record_id}' : undefined;
}

/** An aggregating adapter whose count depends on the filter it is handed. */
function makeAdapter() {
return {
aggregate: vi.fn(async (_object: string, options: { filter?: unknown }) => {
const assignee = assigneeOf(options.filter);
const count = typeof assignee === 'string' ? TASKS_BY_ASSIGNEE[assignee] ?? 0 : 99;
return [{ count }];
}),
find: vi.fn(async () => ({ data: [] })),
getObjectSchema: vi.fn(async (name: string) => ({ name, fields: {} })),
};
}

/** The person's record page: "how many open tasks does THIS person have". */
const OPEN_TASKS = {
type: 'element:number',
id: 'open-tasks',
properties: { object: 'task', aggregate: 'count', filter: { assignee: '{record_id}', status: 'open' } },
};

function Page({ adapter, recordId, schema = OPEN_TASKS }: { adapter: object; recordId?: string; schema?: object }) {
const tree = <SchemaRenderer schema={schema as never} />;
return (
<AdapterCtx.Provider value={adapter as never}>
<FilterScopeProvider currentUserId="usr_viewer" currentOrgId="org_7">
{recordId === undefined ? (
tree
) : (
<RecordContextProvider objectName="person" recordId={recordId}>
{tree}
</RecordContextProvider>
)}
</FilterScopeProvider>
</AdapterCtx.Provider>
);
}

const countBag = (filter: unknown) => ({ field: undefined, function: 'count', groupBy: '_all', filter });

describe('element:number resolves {record_id} against the mounted record (objectui#7297)', () => {
it('counts the record in view, and follows it to the next record: two records, two counts', async () => {
const adapter = makeAdapter();
const { rerender } = render(<Page adapter={adapter} recordId="rec_ada" />);

await waitFor(() => expect(screen.getByText('3')).toBeTruthy());
expect(adapter.aggregate).toHaveBeenLastCalledWith('task', countBag({ assignee: 'rec_ada', status: 'open' }));

// Same tree, same mounted metric; only the record in context moves.
rerender(<Page adapter={adapter} recordId="rec_grace" />);
await waitFor(() => expect(screen.getByText('5')).toBeTruthy());
expect(adapter.aggregate).toHaveBeenLastCalledWith('task', countBag({ assignee: 'rec_grace', status: 'open' }));
expect(screen.queryByText('3')).toBeNull();
});

it('the component-level dataSource.filter takes the token through the same resolution', async () => {
const adapter = makeAdapter();
render(
<Page
adapter={adapter}
recordId="rec_grace"
schema={{
type: 'element:number',
id: 'bound-open-tasks',
dataSource: { object: 'task', filter: [{ field: 'assignee', operator: 'equals', value: '{record_id}' }] },
properties: { aggregate: 'count' },
}}
/>,
);

await waitFor(() => expect(screen.getByText('5')).toBeTruthy());
expect(adapter.aggregate).toHaveBeenLastCalledWith('task', countBag([['assignee', 'equals', 'rec_grace']]));
});

it('the session tokens are unchanged on a record page: {current_user_id} is still the viewer', async () => {
const adapter = makeAdapter();
render(
<Page
adapter={adapter}
recordId="rec_ada"
schema={{
...OPEN_TASKS,
properties: { ...OPEN_TASKS.properties, filter: { assignee: '{record_id}', reviewer: '{current_user_id}' } },
}}
/>,
);

await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled());
expect(adapter.aggregate).toHaveBeenLastCalledWith(
'task',
countBag({ assignee: 'rec_ada', reviewer: 'usr_viewer' }),
);
});
});

describe('element:number refuses {record_id} by name with no record in context (objectui#7297)', () => {
it('left as written — never null, never dropped — and named in the unresolved-token warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const adapter = makeAdapter();
render(<Page adapter={adapter} />);

await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled());
// The condition reaches the query as authored: dropping it would count
// every task (the card's failure), `null` would count none.
expect(adapter.aggregate).toHaveBeenLastCalledWith('task', countBag({ assignee: '{record_id}', status: 'open' }));

const named = warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('"{record_id}"'));
expect(named.length).toBeGreaterThan(0);
expect(named[0]).toContain('no record in context');
expect(named[0]).not.toContain('not a recognised token');
});

it("shows the server's refusal by name, down the path an unresolved {current_user_id} takes", async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
// The ObjectStack server's answer to a filter still carrying a token it has
// no value for: `UnresolvedFilterTokenError`, code + status + a message
// that names the token.
const refusing = (token: string) => ({
...makeAdapter(),
aggregate: vi.fn(async () => {
throw Object.assign(new Error(`Filter token "{${token}}" could not be resolved`), {
code: 'FILTER_TOKEN_UNRESOLVED',
status: 400,
});
}),
});

// Control: an unresolved session token (no user signed in to scope).
const sessionAdapter = refusing('current_user_id');
render(
<AdapterCtx.Provider value={sessionAdapter as never}>
<SchemaRenderer
schema={{ ...OPEN_TASKS, properties: { ...OPEN_TASKS.properties, filter: { owner: '{current_user_id}' } } } as never}
/>
</AdapterCtx.Provider>,
);
await waitFor(() => expect(screen.getByText('Filter token "{current_user_id}" could not be resolved')).toBeTruthy());
cleanup();

const recordAdapter = refusing('record_id');
render(<Page adapter={recordAdapter} />);
await waitFor(() => expect(screen.getByText('Filter token "{record_id}" could not be resolved')).toBeTruthy());
// A refusal, not a number: the value slot shows the empty dash.
expect(screen.getByText('—')).toBeTruthy();
});
});
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export * from './runtime/capabilities.js';
export { composeStacks } from '@objectstack/spec';
export * from './utils/drill-down.js';
export * from './utils/date-macros.js';
// Session-scoped filter placeholders ({current_user_id} / {current_org_id})
// Session-scoped filter placeholders ({current_user_id} / {current_org_id}),
// the record-scoped `{record_id}` (objectui#7297),
// plus `resolveFilterPlaceholders`, the single entry point every surface
// should call so no vocabulary is silently skipped (objectstack-ai/objectstack#3574).
export * from './utils/filter-tokens.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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.
*/

/**
* objectui#7297 — `{record_id}`, the record-scoped filter token
* `@objectstack/spec` declares beside the two session tokens
* (`RECORD_CONTEXT_TOKENS`, objectstack-ai/objectstack#20003).
*
* The resolver fills it from `FilterTokenScope.recordId` (which
* `useFilterScope()` in `@object-ui/react` reads from the mounted
* `RecordContextProvider`) and from nothing else. With no record in scope it is
* refused BY NAME through `onUnresolved`, the channel an unresolved session
* token uses, and left as written: never `null`, never dropped, and never
* reported as an unknown spelling.
*/

import { describe, it, expect, vi } from 'vitest';
import { RECORD_CONTEXT_TOKENS } from '@objectstack/spec/data';

import { resolveContextTokens, resolveFilterPlaceholders } from '../filter-tokens';

const SESSION = { currentUserId: 'usr_42', currentOrgId: 'org_7' };

/** Resolve one filter and report both halves of the observable result. */
function resolve(filter: unknown, scope: Record<string, unknown>) {
const warnings: string[] = [];
const out = resolveContextTokens(filter, { ...scope, onUnresolved: (m: string) => warnings.push(m) });
return { out, warnings };
}

describe('{record_id} resolves against the record in scope (objectui#7297)', () => {
it('is the one record-context token the installed spec declares', () => {
// The resolver's `satisfies Record<RecordContextToken, …>` literal reds the
// compile on a second one; this states the same ratchet at runtime.
expect([...RECORD_CONTEXT_TOKENS]).toEqual(['record_id']);
});

it('resolves in every filter shape, in both spellings', () => {
const scope = { ...SESSION, recordId: 'rec_A' };
expect(resolve({ assignee: '{record_id}', status: 'open' }, scope)).toEqual({
out: { assignee: 'rec_A', status: 'open' },
warnings: [],
});
expect(resolve([{ field: 'assignee', operator: 'equals', value: '{record_id}' }], scope).out).toEqual([
{ field: 'assignee', operator: 'equals', value: 'rec_A' },
]);
expect(resolve([['assignee', '=', '{record_id}']], scope).out).toEqual([['assignee', '=', 'rec_A']]);
expect(resolve({ $and: [{ assignee: { $in: ['${record_id}'] } }] }, scope).out).toEqual({
$and: [{ assignee: { $in: ['rec_A'] } }],
});
});

it('follows the record: two records, two resolutions', () => {
const filter = { assignee: '{record_id}' };
expect(resolve(filter, { recordId: 'rec_A' }).out).toEqual({ assignee: 'rec_A' });
expect(resolve(filter, { recordId: 'rec_B' }).out).toEqual({ assignee: 'rec_B' });
});

it('resolves alongside the session tokens without either reading the other', () => {
const { out, warnings } = resolve(
{ assignee: '{record_id}', owner: '{current_user_id}', org: '{current_org_id}' },
{ ...SESSION, recordId: 'rec_A' },
);
expect(out).toEqual({ assignee: 'rec_A', owner: 'usr_42', org: 'org_7' });
expect(warnings).toEqual([]);
});

it('takes nothing from the session: a signed-in scope with no record refuses it', () => {
const { out, warnings } = resolve({ assignee: '{record_id}' }, SESSION);
expect(out).toEqual({ assignee: '{record_id}' });
expect(warnings).toHaveLength(1);
});

it('gives the session tokens nothing: a record id does not resolve {current_user_id}', () => {
const { out, warnings } = resolve({ owner: '{current_user_id}' }, { recordId: 'rec_A' });
expect(out).toEqual({ owner: '{current_user_id}' });
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('"{current_user_id}"');
});
});

describe('{record_id} with no record in scope is refused by name (objectui#7297)', () => {
for (const [label, scope] of [
['no recordId member', {}],
['recordId null', { recordId: null }],
['recordId undefined', { recordId: undefined }],
['recordId empty', { recordId: '' }],
] as const) {
it(`${label}: left as written, one warning naming the token and the missing record`, () => {
const { out, warnings } = resolve({ assignee: '{record_id}', status: 'open' }, { ...SESSION, ...scope });
// Left as written: not `null` (a count about nobody), and the condition is
// still there (dropping it would count everybody).
expect(out).toEqual({ assignee: '{record_id}', status: 'open' });
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('"{record_id}"');
expect(warnings[0]).toContain('no record in context');
// Refused as a KNOWN token on the wrong surface, never as a misspelling:
// the spelling is right and the surface is not.
expect(warnings[0]).not.toContain('not a recognised token');
expect(warnings[0]).not.toContain('did you mean');
});
}

it('takes the ${record_id} spelling down the same refusal', () => {
const { out, warnings } = resolve({ assignee: '${record_id}' }, SESSION);
expect(out).toEqual({ assignee: '${record_id}' });
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('"{record_id}"');
});

it('warns on console by default, the same default an unresolved session token has', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
expect(resolveContextTokens({ assignee: '{record_id}' })).toEqual({ assignee: '{record_id}' });
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('"{record_id}"');
} finally {
warn.mockRestore();
}
});

it('is refused through resolveFilterPlaceholders too, the call every surface makes', () => {
const warnings: string[] = [];
const out = resolveFilterPlaceholders(
{ assignee: '{record_id}', due: { $lte: '{today}' } },
{ ...SESSION, onUnresolved: (m) => warnings.push(m) },
new Date('2026-09-30T12:00:00Z'),
);
expect(out.assignee).toBe('{record_id}');
expect(out.due.$lte).not.toBe('{today}');
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('"{record_id}"');
});
});
Loading
Loading