diff --git a/.changeset/7297-record-id-filter-token.md b/.changeset/7297-record-id-filter-token.md new file mode 100644 index 0000000000..d6416e7f6a --- /dev/null +++ b/.changeset/7297-record-id-filter-token.md @@ -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. diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index a1c41e7854..f12e23811b 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -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* diff --git a/packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx new file mode 100644 index 0000000000..1499cd3cdf --- /dev/null +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx @@ -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 = { 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 = ; + return ( + + + {recordId === undefined ? ( + tree + ) : ( + + {tree} + + )} + + + ); +} + +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(); + + 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(); + 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( + , + ); + + 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( + , + ); + + 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(); + + 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( + + + , + ); + await waitFor(() => expect(screen.getByText('Filter token "{current_user_id}" could not be resolved')).toBeTruthy()); + cleanup(); + + const recordAdapter = refusing('record_id'); + render(); + 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(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0a56ef2869..98cbc64e71 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/utils/__tests__/filter-tokens.recordContext-7297.test.ts b/packages/core/src/utils/__tests__/filter-tokens.recordContext-7297.test.ts new file mode 100644 index 0000000000..4e4d79aa0f --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-tokens.recordContext-7297.test.ts @@ -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) { + 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` 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}"'); + }); +}); diff --git a/packages/core/src/utils/filter-tokens.ts b/packages/core/src/utils/filter-tokens.ts index 5c3f2e5bc4..0f7bd91c28 100644 --- a/packages/core/src/utils/filter-tokens.ts +++ b/packages/core/src/utils/filter-tokens.ts @@ -10,6 +10,8 @@ import { CONTEXT_TOKENS, CONTEXT_TOKEN_SUGGESTIONS, isContextToken, + isRecordContextToken, + type RecordContextToken, } from '@objectstack/spec/data'; import { resolveDateMacros } from './date-macros.js'; @@ -48,6 +50,25 @@ import { resolveDateMacros } from './date-macros.js'; * Dropping a `{current_user_id}` filter widens a view; it must never widen * access. Never use a context token as an access control. * + * ## `{record_id}` — the record in view, not the session (objectui#7297) + * + * `@objectstack/spec` declares one sibling of the two session tokens: + * `RECORD_CONTEXT_TOKENS` = `{record_id}`, the id of the record a + * `type: 'record'` page is showing (objectstack-ai/objectstack#20003). It is a + * separate list, not a member of `CONTEXT_TOKENS`, because it resolves against + * the SURFACE rather than the session, and only here: the server never knows + * which record a page shows, so it refuses a filter still carrying the token + * by name (`FILTER_TOKEN_UNRESOLVED`). + * + * Its value is `FilterTokenScope.recordId`, which `useFilterScope()` in + * `@object-ui/react` fills from the mounted `RecordContextProvider` and from + * nothing else — never a URL parameter, never a page variable. With no record + * in scope the token is refused by name through `onUnresolved`, in the voice + * an unresolved session token gets, and left as written: it never becomes + * `null` (a count about nobody) and its condition is never dropped (a count + * about everybody). Like the session tokens it scopes what a record page + * SHOWS; which of those rows the caller may read is still RLS's decision. + * * ## Contract source * * The canonical vocabulary is published as part of the platform contract at @@ -88,10 +109,20 @@ export interface FilterTokenScope { currentUserId?: string | null; /** The active organization id. */ currentOrgId?: string | null; + /** + * The id of the record the surface is bound to, which resolves + * `{record_id}` (objectui#7297): the mounted record context of a + * `type: 'record'` page. `useFilterScope()` in `@object-ui/react` fills it + * from the nearest `RecordContextProvider`, and a host that renders no + * record leaves it unset, so the token is refused there. ⛔ Never fill it + * from a URL parameter or a page variable. + */ + recordId?: string | null; /** * Called when a placeholder cannot be resolved — either a recognised token - * with no value in scope (signed out), or a near-miss spelling that resolves - * in no vocabulary. Defaults to a `console.warn`; pass `null` to silence. + * with no value in scope (signed out, or no record in context), or a + * near-miss spelling that resolves in no vocabulary. Defaults to a + * `console.warn`; pass `null` to silence. */ onUnresolved?: ((message: string) => void) | null; } @@ -117,13 +148,14 @@ const WHOLE_TOKEN_RE = /^\$?\{([a-zA-Z0-9_]+)\}$/; * `@objectstack/spec`'s own map is left untouched (out of scope here; the * identical shape in its `classifyFilterToken` is the one objectstack `4342c9923` closed). */ -const NEAR_MISS_SUGGESTIONS: Readonly> = Object.assign( +const NEAR_MISS_SUGGESTIONS: Readonly> = Object.assign( Object.create(null), CONTEXT_TOKEN_SUGGESTIONS, ); /** - * Expand `{current_user_id}` / `{current_org_id}` inside a filter. + * Expand `{current_user_id}` / `{current_org_id}`, and `{record_id}` where a + * record is in scope, inside a filter. * * Walks arrays and plain objects recursively, which is what makes one * resolver cover both platform filter shapes: the MongoDB-style object a @@ -147,7 +179,7 @@ const NEAR_MISS_SUGGESTIONS: Readonly> = Object export function resolveContextTokens(filter: T, scope: FilterTokenScope = {}): T { if (filter == null) return filter; - const { currentUserId, currentOrgId } = scope; + const { currentUserId, currentOrgId, recordId } = scope; const warn = scope.onUnresolved === null ? () => {} @@ -166,6 +198,13 @@ export function resolveContextTokens(filter: T, scope: FilterTokenScope current_user_id: currentUserId, current_org_id: currentOrgId, } satisfies Record; + // The record-context sibling, kept apart for the reason the spec keeps the + // two lists apart: it is filled from the record in view, never from the + // session. The same `satisfies` ratchet: a second record-context token in + // the spec reds this literal at compile time. + const recordValues: Record = { + record_id: recordId, + } satisfies Record; const walk = (value: any): any => { if (value == null) return value; @@ -186,6 +225,26 @@ export function resolveContextTokens(filter: T, scope: FilterTokenScope return value; } + // `{record_id}` (objectui#7297). Resolved only from the record in scope. + // With none, it is refused BY NAME, in the voice of the unresolved + // session token above, and left as written: never `null` (a count about + // nobody), never dropped (a count about everybody), and never reported as + // an unknown spelling, because the spelling is right and the surface is + // not. The ObjectStack server then refuses the same filter by name + // (`FILTER_TOKEN_UNRESOLVED`), since no server path has a record in view. + if (isRecordContextToken(token)) { + const resolved = recordValues[token]; + if (resolved != null && resolved !== '') return resolved; + warn( + `Filter placeholder "{${token}}" could not be resolved — no record in context ` + + `on this surface. It resolves only on a component of a \`type: 'record'\` page, ` + + `to the id of the record that page shows. It is left as written, so the filter ` + + `never widens: the server refuses it by name, and a backend with no resolver ` + + `matches no records.`, + ); + return value; + } + // Not ours. Warn only for near-misses, which resolve in no vocabulary // at all and would otherwise fail silently; genuine date macros and // nav context-selector ids must pass through quietly. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 35f06896d3..c4c98c9a74 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -97,6 +97,7 @@ interface HeldGridFilters { authored: AuthoredGridFilters; currentUserId: FilterTokenScope['currentUserId']; currentOrgId: FilterTokenScope['currentOrgId']; + recordId: FilterTokenScope['recordId']; onUnresolved: FilterTokenScope['onUnresolved']; resolved: AuthoredGridFilters; } @@ -106,6 +107,7 @@ function resolveGridFilters(authored: AuthoredGridFilters, scope: FilterTokenSco authored, currentUserId: scope.currentUserId, currentOrgId: scope.currentOrgId, + recordId: scope.recordId, onUnresolved: scope.onUnresolved, resolved: resolveFilterPlaceholders(authored, scope), }; @@ -170,8 +172,9 @@ function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean { * every render; and a date macro such as `{now}` resolves to a new value at * every call, so comparing OUTPUTS cannot stop that. The key is the authored * filters, compared by structure (a host that rebuilds an equal filter inline - * must not re-query), plus the scope's three members read one by one, never - * the scope object's identity (AGENTS.md #10). The held pair lives in state, + * must not re-query), plus each scope member read one by one (`recordId` + * among them, objectui#7297), never the scope object's identity (AGENTS.md + * #10). The held pair lives in state, * so the value handed out is always the one React committed. */ function useResolvedGridFilters(authored: AuthoredGridFilters, scope: FilterTokenScope): AuthoredGridFilters { @@ -179,6 +182,7 @@ function useResolvedGridFilters(authored: AuthoredGridFilters, scope: FilterToke if ( held.currentUserId !== scope.currentUserId || held.currentOrgId !== scope.currentOrgId + || held.recordId !== scope.recordId || held.onUnresolved !== scope.onUnresolved || !isSameAuthoredFilter(held.authored, authored) ) { diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.recordIdFilterToken-7297.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.recordIdFilterToken-7297.test.tsx new file mode 100644 index 0000000000..1b09223b44 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.recordIdFilterToken-7297.test.tsx @@ -0,0 +1,98 @@ +/** + * 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 — a directly authored `object-grid` on a record page resolves + * `{record_id}` to the mounted record (`RecordContextProvider`, through + * `useFilterScope()`), and its held filter follows that record: the hold keys + * on `recordId` like every other scope member, so moving to the next record + * re-queries without a remount. Outside a record context the token is left as + * written and named in the resolver's warning. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, FilterScopeProvider, RecordContextProvider } from '@object-ui/react'; +import type { BaseSchema, DataSource } from '@object-ui/types'; + +import '../index'; + +function makeAdapter() { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Write the brief', assignee: 'rec_ada' }], total: 1 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'task', + fields: { id: { type: 'text' }, name: { type: 'text' }, assignee: { type: 'text' } }, + }), + }; +} + +type Adapter = ReturnType; + +const NODE = { type: 'object-grid', objectName: 'task', columns: ['name'], filter: [['assignee', '=', '{record_id}']] }; + +function ui(adapter: Adapter, recordId?: string) { + const grid = ( + + + + ); + return ( + + {recordId === undefined ? grid : ( + {grid} + )} + + ); +} + +async function queriedFilter(find: Adapter['find'], call = 0) { + await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call)); + return (find.mock.calls[call] as unknown as [string, { $filter?: unknown }])[1]?.$filter; +} + +let warn: ReturnType; + +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + warn.mockRestore(); +}); + +describe('object-grid — {record_id} is the mounted record (objectui#7297)', () => { + it('queries with the record in view, and again with the next one, without a remount', async () => { + const adapter = makeAdapter(); + const { rerender } = render(ui(adapter, 'rec_ada')); + expect(await queriedFilter(adapter.find, 0)).toEqual([['assignee', '=', 'rec_ada']]); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + const before = adapter.find.mock.calls.length; + await act(async () => { + rerender(ui(adapter, 'rec_grace')); + }); + expect(await queriedFilter(adapter.find, before)).toEqual([['assignee', '=', 'rec_grace']]); + }); + + it('with no record in context: left as written, and named in the warning', async () => { + const adapter = makeAdapter(); + render(ui(adapter)); + expect(await queriedFilter(adapter.find, 0)).toEqual([['assignee', '=', '{record_id}']]); + expect( + warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('"{record_id}"') && String(c[0]).includes('no record in context')), + ).toBe(true); + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 9b0570cc1f..0b869fa477 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -439,6 +439,7 @@ function useResolvedFilterSegments( if ( held.currentUserId !== scope.currentUserId || held.currentOrgId !== scope.currentOrgId + || held.recordId !== scope.recordId || held.onUnresolved !== scope.onUnresolved || !isStructurallyEqual(held.segments, segments) ) { @@ -457,6 +458,7 @@ interface HeldFilterSegments { segments: AuthoredFilterSegments; currentUserId: FilterTokenScope['currentUserId']; currentOrgId: FilterTokenScope['currentOrgId']; + recordId: FilterTokenScope['recordId']; onUnresolved: FilterTokenScope['onUnresolved']; resolved: AuthoredFilterSegments; } @@ -466,6 +468,7 @@ function resolveFilterSegments(segments: AuthoredFilterSegments, scope: FilterTo segments, currentUserId: scope.currentUserId, currentOrgId: scope.currentOrgId, + recordId: scope.recordId, onUnresolved: scope.onUnresolved, resolved: resolveFilterPlaceholders(segments, scope), }; diff --git a/packages/plugin-view/src/__tests__/ObjectView.recordIdFilterToken-7297.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.recordIdFilterToken-7297.test.tsx new file mode 100644 index 0000000000..af2794ae17 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.recordIdFilterToken-7297.test.tsx @@ -0,0 +1,100 @@ +/** + * 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 — an `object-view` on a record page resolves `{record_id}` in + * its named view's filter to the mounted record (`RecordContextProvider`, + * through `useFilterScope()`), and its held segments follow that record: the + * hold keys on `recordId` like every other scope member, so moving to the next + * record re-queries without a remount. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, act } from '@testing-library/react'; +import { FilterScopeProvider, RecordContextProvider } from '@object-ui/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const React = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) =>
{schema?.type}
, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); + +function makeAdapter() { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), + }; +} + +/** A calendar view, so the filter leaves by the non-grid `find()` this component issues itself. */ +const SCHEMA = { + type: 'object-view', + objectName: 'task', + defaultListView: 'theirs', + listViews: { + theirs: { + label: 'Their tasks', + type: 'calendar', + columns: ['name'], + filter: [{ field: 'assignee', operator: 'equals', value: '{record_id}' }], + }, + }, +} as ObjectViewSchema; + +function ui(ds: ReturnType, recordId: string) { + return ( + + + + + + ); +} + +async function queriedFilter(find: ReturnType, call = 0) { + await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call)); + return find.mock.calls[call][1]?.$filter; +} + +let warn: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + warn.mockRestore(); +}); + +describe('object-view — {record_id} in a named view is the mounted record (objectui#7297)', () => { + it('queries with the record in view, and again with the next one, without a remount', async () => { + const ds = makeAdapter(); + const { rerender } = render(ui(ds, 'rec_ada')); + expect(await queriedFilter(ds.find, 0)).toEqual([['assignee', 'equals', 'rec_ada']]); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + const before = ds.find.mock.calls.length; + await act(async () => { + rerender(ui(ds, 'rec_grace')); + }); + expect(await queriedFilter(ds.find, before)).toEqual([['assignee', 'equals', 'rec_grace']]); + }); +}); diff --git a/packages/react/README.md b/packages/react/README.md index dba85b03ee..a1f0c78bfe 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -281,12 +281,24 @@ adding a separate enable flag. ### useFilterScope / useResolvedFilter A data node that sends its own authored `filter` into a query resolves the -spec's context tokens in it first: `{current_user_id}`, `{current_org_id}` and -the date macros such as `{today}` (objectui#10666). `useFilterScope()` reads -the session scope a host mounts with `FilterScopeProvider`; +spec's context tokens in it first: `{current_user_id}`, `{current_org_id}`, +`{record_id}` and the date macros such as `{today}` (objectui#10666). +`useFilterScope()` reads the session scope a host mounts with +`FilterScopeProvider`, plus the id of the record in view when a +`RecordContextProvider` is mounted above the node (objectui#7297); `useResolvedFilter(filter, scope)` resolves the filter through `@object-ui/core`'s `resolveFilterPlaceholders` and HOLDS the result. +`{record_id}` is the id of the record a `type: 'record'` page shows, taken from +that page's mounted record context and never from a URL parameter or a page +variable: on a person's record page, `{ assignee: '{record_id}' }` counts that +person's tasks. With no record in context (a list view, a dashboard, a page +that is not a record page) it is refused by name, with the same warning an +unresolved `{current_user_id}` gets, and left as written, so the filter never +widens. It scopes what a component shows; it is not access control, which +stays with the server's row-level security. `FilterScopeProvider` carries the +session values only. + ```tsx import { useEffect } from 'react' import { useFilterScope, useResolvedFilter } from '@object-ui/react' @@ -311,9 +323,9 @@ function ObjectSomething({ The held value keeps its reference while the authored filter is structurally equal (a filter rebuilt inline on every render included) and the scope's user, -organization and `onUnresolved` are unchanged, so the effect above runs once. -A structurally different filter, or a new signed-in user or organization, -resolves again. A filter with no token to resolve is handed back as the value +organization, record and `onUnresolved` are unchanged, so the effect above runs +once. A structurally different filter, a new signed-in user or organization, or +another record in view resolves again. A filter with no token to resolve is handed back as the value passed in. A token the scope cannot resolve is left in place, and the resolver logs one warning naming it. diff --git a/packages/react/src/hooks/__tests__/useFilterScope.recordContext-7297.test.tsx b/packages/react/src/hooks/__tests__/useFilterScope.recordContext-7297.test.tsx new file mode 100644 index 0000000000..0819e5753d --- /dev/null +++ b/packages/react/src/hooks/__tests__/useFilterScope.recordContext-7297.test.tsx @@ -0,0 +1,127 @@ +/** + * 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 — `useFilterScope()` hands `{record_id}` the MOUNTED record. + * + * The scope every data node resolves its filter against is the session scope + * `FilterScopeProvider` publishes plus `recordId`, read from the nearest + * `RecordContextProvider` (the provider whose row a component's `visibleWhen` + * binds as `record`) and from nothing else. Outside one there is no `recordId`, + * so the resolver refuses the token by name. `useResolvedFilter` holds on + * `recordId` like every other scope member, so moving to another record resolves + * again without a remount. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, cleanup } from '@testing-library/react'; +import { FilterScopeProvider, useFilterScope } from '../useFilterScope'; +import { useResolvedFilter } from '../useResolvedFilter'; +import { RecordContextProvider } from '../../context/RecordContext'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +type Mount = { recordId?: string | number | null; withRecord: boolean }; + +/** Session provider always; a record provider only when `withRecord`. */ +function wrapperFor(props: Mount) { + return function Wrapper({ children }: { children: React.ReactNode }) { + const inner = props.withRecord ? ( + + {children} + + ) : ( + children + ); + return ( + + {inner} + + ); + }; +} + +describe('useFilterScope reads the mounted record context (objectui#7297)', () => { + it('outside a record context: the session scope, with no recordId', () => { + const { result } = renderHook(() => useFilterScope(), { wrapper: wrapperFor({ withRecord: false }) }); + expect(result.current).toEqual({ currentUserId: 'usr_42', currentOrgId: 'org_7' }); + expect('recordId' in result.current).toBe(false); + }); + + it('inside one: the session scope plus the mounted record id', () => { + const { result } = renderHook(() => useFilterScope(), { + wrapper: wrapperFor({ withRecord: true, recordId: 'rec_A' }), + }); + expect(result.current).toEqual({ currentUserId: 'usr_42', currentOrgId: 'org_7', recordId: 'rec_A' }); + }); + + it('takes the id as the record provider narrowed it (a numeric key arrives as a string)', () => { + const { result } = renderHook(() => useFilterScope(), { + wrapper: wrapperFor({ withRecord: true, recordId: 7 }), + }); + expect(result.current.recordId).toBe('7'); + }); + + it('a record provider with no record bound adds nothing', () => { + const { result } = renderHook(() => useFilterScope(), { + wrapper: wrapperFor({ withRecord: true, recordId: null }), + }); + expect(result.current).toEqual({ currentUserId: 'usr_42', currentOrgId: 'org_7' }); + }); + + it('with no session provider at all, the record id still arrives on its own', () => { + const { result } = renderHook(() => useFilterScope(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + expect(result.current).toEqual({ recordId: 'rec_A' }); + }); +}); + +describe('useResolvedFilter follows the mounted record (objectui#7297)', () => { + function useScopedFilter(filter: unknown) { + return useResolvedFilter(filter, useFilterScope()); + } + + it('resolves {record_id} to the record in view, and again when the record changes', () => { + const mount: Mount = { withRecord: true, recordId: 'rec_A' }; + const filter = { assignee: '{record_id}' }; + // One wrapper whose record id is read at render time, so the SAME hook + // instance sees the record change: no remount, the way a record page + // moves from one record to the next. + const Wrapper = ({ children }: { children: React.ReactNode }) => wrapperFor(mount)({ children }); + const { result, rerender } = renderHook(() => useScopedFilter(filter), { wrapper: Wrapper }); + expect(result.current).toEqual({ assignee: 'rec_A' }); + const first = result.current; + + rerender(); + expect(result.current).toBe(first); // same record, same held value + + mount.recordId = 'rec_B'; + rerender(); + expect(result.current).toEqual({ assignee: 'rec_B' }); + }); + + it('outside a record context the token is left as written and named in a warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook(() => useScopedFilter({ assignee: '{record_id}' }), { + wrapper: wrapperFor({ withRecord: false }), + }); + expect(result.current).toEqual({ assignee: '{record_id}' }); + const named = warn.mock.calls.filter((c) => String(c[0]).includes('"{record_id}"')); + expect(named.length).toBeGreaterThan(0); + expect(String(named[0][0])).toContain('no record in context'); + }); +}); diff --git a/packages/react/src/hooks/useFilterScope.ts b/packages/react/src/hooks/useFilterScope.ts index a4ec6c141c..c021277ffb 100644 --- a/packages/react/src/hooks/useFilterScope.ts +++ b/packages/react/src/hooks/useFilterScope.ts @@ -8,10 +8,13 @@ import { createContext, createElement, useContext, useMemo, type ReactNode } from 'react'; import type { FilterTokenScope } from '@object-ui/core'; +import { useRecordContext } from '../context/RecordContext.js'; /** * The session values filter placeholders resolve against — `{current_user_id}` - * and `{current_org_id}` (objectstack-ai/objectstack#3574). + * and `{current_org_id}` (objectstack-ai/objectstack#3574). The record-scoped + * `{record_id}` is not a session value and is not provided here: see + * {@link useFilterScope}. * * ## Why a dedicated context * @@ -59,13 +62,38 @@ export function FilterScopeProvider({ } /** - * Read the session scope for filter placeholder resolution. + * Read the scope for filter placeholder resolution: the session scope the host + * mounted, plus the id of the record in view when there is one. * * Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from * `@object-ui/core` — that helper expands every placeholder vocabulary in one * call, which is the point: resolving only some of them is the defect behind * objectstack-ai/objectstack#3574. + * + * ## `{record_id}` reads the MOUNTED record (objectui#7297) + * + * `recordId` comes from the nearest `RecordContextProvider` — the record a + * `type: 'record'` page is showing, the same provider whose row a component's + * `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL + * parameter, not a page variable, and not a prop of `FilterScopeProvider`, + * which stays session-only. Every data node that already resolves its filter + * through this hook therefore resolves `{record_id}` on a record page without + * a line of its own, and refuses it by name everywhere else (a list view, a + * dashboard, a page that is not a record page), because there the member is + * simply absent. + * + * Outside a record context the session scope is handed back as the very + * object the provider published, so nothing changes for a surface that shows + * no record. Inside one, the combined scope is rebuilt only when the session + * scope or the record id changes. A consumer that holds a resolution compares + * the members one by one — `recordId` among them — never this object's + * identity (AGENTS.md #10). */ export function useFilterScope(): FilterTokenScope { - return useContext(FilterScopeContext); + const session = useContext(FilterScopeContext); + const recordId = useRecordContext()?.recordId; + return useMemo( + () => (recordId == null ? session : { ...session, recordId }), + [session, recordId], + ); } diff --git a/packages/react/src/hooks/useResolvedFilter.ts b/packages/react/src/hooks/useResolvedFilter.ts index 3723744620..9673739000 100644 --- a/packages/react/src/hooks/useResolvedFilter.ts +++ b/packages/react/src/hooks/useResolvedFilter.ts @@ -26,6 +26,7 @@ interface HeldFilter { authored: unknown; currentUserId: FilterTokenScope['currentUserId']; currentOrgId: FilterTokenScope['currentOrgId']; + recordId: FilterTokenScope['recordId']; onUnresolved: FilterTokenScope['onUnresolved']; resolved: unknown; } @@ -36,6 +37,7 @@ function resolveHeldFilter(authored: unknown, scope: FilterTokenScope): HeldFilt authored, currentUserId: scope.currentUserId, currentOrgId: scope.currentOrgId, + recordId: scope.recordId, onUnresolved: scope.onUnresolved, // `resolveFilterPlaceholders` copies every array and plain object it walks, // so a filter with nothing to resolve comes back EQUAL but not the same @@ -91,9 +93,11 @@ function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean { * Resolve a data node's own authored `filter` ONCE, and hold the result. * * Every context token the spec defines for filters (`{current_user_id}`, - * `{current_org_id}`, and the date macros such as `{today}`) is expanded by - * `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the session - * scope the host provides. Pass `useFilterScope()` as `scope`: + * `{current_org_id}`, `{record_id}` where a record is in view, and the date + * macros such as `{today}`) is expanded by `@object-ui/core`'s shared + * `resolveFilterPlaceholders`, against the scope `useFilterScope()` reads: the + * session scope the host provides plus the mounted record's id + * (objectui#7297). Pass `useFilterScope()` as `scope`: * * ```tsx * const filterScope = useFilterScope(); @@ -111,8 +115,9 @@ function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean { * date macro such as `{now}` resolves to a new value at every call, so * comparing OUTPUTS cannot stop that. The key is the authored filter, compared * by structure (a host that rebuilds an equal filter inline must not - * re-query), plus the scope's three members read one by one, never the scope - * object's identity (AGENTS.md #10). The held pair lives in state, so the + * re-query), plus each scope member read one by one (`recordId` among them, + * so moving to another record resolves again), never the scope object's + * identity (AGENTS.md #10). The held pair lives in state, so the * value handed out is always the one React committed. A structurally different * filter, or a changed scope member, resolves again and hands out a new value. * @@ -126,6 +131,7 @@ export function useResolvedFilter(authored: T, scope: FilterTokenScope): T { if ( held.currentUserId !== scope.currentUserId || held.currentOrgId !== scope.currentOrgId + || held.recordId !== scope.recordId || held.onUnresolved !== scope.onUnresolved || !isSameAuthoredFilter(held.authored, authored) ) {