diff --git a/.changeset/10666-filter-token-sweep.md b/.changeset/10666-filter-token-sweep.md new file mode 100644 index 0000000000..39f3445338 --- /dev/null +++ b/.changeset/10666-filter-token-sweep.md @@ -0,0 +1,33 @@ +--- +'@object-ui/react': minor +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-map': patch +'@object-ui/plugin-timeline': patch +'@object-ui/plugin-tree': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-list': patch +'@object-ui/components': patch +'@object-ui/types': patch +--- + +Every data node that sends its own authored `filter` into a query now resolves the spec's context tokens first (objectui#10666). With `filter: [['owner', '=', '{current_user_id}']]` these nodes used to send the literal token; they now send the signed-in user's id, and `{current_org_id}` resolves to the active organization: + +- `object-calendar`, `object-map` and `object-tree`, on both the object query and the inline (`provider: 'value'`) query; +- `object-gantt`, `object-kanban` and `object-timeline`; +- `record:related_list` and `record:line_items`, where the node's own filter is combined with the parent-record condition; +- the `element:repeater`, `element:number` (both the `aggregate` filter and the `find` fallback) and `element:record_picker` page elements. + +`list-view`, `object-grid` and `object-gallery` already did this, and are unchanged. + +**New in `@object-ui/react`: `useResolvedFilter(filter, scope)`.** Pass it a node's authored filter and `useFilterScope()`. It resolves the filter through `@object-ui/core`'s shared `resolveFilterPlaceholders` and holds the result: re-rendering with an equal filter (even one rebuilt inline on every render) hands back the same value, so a fetch effect keyed on it does not run again, while a structurally different filter or a change of signed-in user or organization resolves again. A filter with no token to resolve is handed back as the value you passed in. The hold is the one `list-view` and `object-gallery` used privately; `plugin-list` now imports it from here, and no second copy remains in that package. + +What changes for an app: + +- A filter with no placeholders reaches the query as before, unchanged. +- Nodes that re-queried whenever the host rebuilt an equal filter inline (`object-calendar`, `object-gantt`, `object-kanban`, `object-map`, `object-tree`) no longer issue those redundant queries. +- Date macros such as `{today}` in these nodes' own filters are now resolved in the browser's local time, as they already are for `list-view`, `object-grid` and `object-view`. Before, they reached the server as literals, and the server resolved them in the tenant's time zone. + +`@object-ui/types`: the `filter` docblocks on `ObjectMapSchema`, `ObjectTreeSchema`, `ObjectGanttSchema`, `ObjectCalendarSchema`, `ObjectKanbanSchema`, `ObjectChartSchema` and `ObjectGallerySchema` (and the matching zod descriptions) no longer say the filter is forwarded verbatim; they say its context tokens are resolved first. No type changes. diff --git a/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx new file mode 100644 index 0000000000..d387d2a1c0 --- /dev/null +++ b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx @@ -0,0 +1,378 @@ +/** + * 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#10666 — the family closure pin: EVERY data node that sends its own + * authored `filter` into a data-source query resolves the spec's context + * tokens (`{current_user_id}`, `{current_org_id}`) first, through + * `@object-ui/core`'s ONE shared `resolveFilterPlaceholders`, against the + * session scope the host provides (`FilterScopeProvider` / `useFilterScope`), + * and HOLDS the resolved value so that no query fires again on a re-render. + * + * One enumeration table, one row per node (and per query path where a node + * has two). Each row renders the node DIRECTLY AUTHORED, through the real + * `SchemaRenderer` and the node's own package registration, under a + * `FilterScopeProvider`, over a data source that records every query it is + * asked. The inline (`provider: 'value'`) paths query a `ValueDataSource` + * built inside the node, so those rows record its `find` instead. + * + * The four cells per row: + * - `{current_user_id}` goes out as the signed-in user id; + * - `{current_org_id}` goes out as the active organization id; + * - a token-free filter goes out unchanged: the query equals, member for + * member, the one the same node sends for the literal filter; + * - re-renders that rebuild an equal (token-carrying) filter add no query. + * + * `object-grid`, `list-view` and `object-gallery` are regression rows: they + * were fixed by objectui#10607 and by the first half of objectui#10666, and + * their own pins live in their packages. + * + * ## Why this file lives in `apps/console` + * + * It renders nodes from nine packages in one table, so it must live in a + * package that declares all of them (the objectui#4409 dependency-direction + * method). `app-shell` lacks `plugin-gantt`, `plugin-map`, `plugin-timeline` + * and `plugin-tree`; this app declares every one. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// No WebGL in the test env: the stub the `plugin-map` tests use. This app does +// not declare `react-map-gl`, so the bare specifier does not resolve from here; +// the mock names the file `plugin-map`'s own `react-map-gl/maplibre` import +// resolves to (its `exports['./maplibre'].import`), which is the module id the +// mock has to match. +vi.mock('../../../../packages/plugin-map/node_modules/react-map-gl/dist/maplibre.js', () => ({ + default: ({ children }: { children?: React.ReactNode }) =>
{children}
, + Map: ({ children }: { children?: React.ReactNode }) =>
{children}
, + NavigationControl: () =>
, + Marker: ({ children }: { children?: React.ReactNode }) =>
{children}
, + Popup: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})); +import { render, act, cleanup, waitFor } from '@testing-library/react'; +import { ComponentRegistry, ValueDataSource } from '@object-ui/core'; +import { + AdapterCtx, + FilterScopeProvider, + RecordContextProvider, + SchemaRenderer, + SchemaRendererProvider, +} from '@object-ui/react'; +// Each import registers its package's nodes, the way the console loads them. +import '@object-ui/components'; +import '@object-ui/plugin-calendar'; +import '@object-ui/plugin-detail'; +import '@object-ui/plugin-form'; +import '@object-ui/plugin-gantt'; +import '@object-ui/plugin-grid'; +import '@object-ui/plugin-kanban'; +import '@object-ui/plugin-list'; +import '@object-ui/plugin-map'; +import '@object-ui/plugin-timeline'; +import '@object-ui/plugin-tree'; + +const USER = 'usr_42'; +const ORG = 'org_7'; +const OBJECT = 'task'; + +const ROW = { + id: '1', + name: 'Acme', + owner: USER, + org: ORG, + status: 'open', + parent_id: null, + account_id: 'ACC-1', + invoice: 'inv-1', + starts_at: '2026-09-01T09:00:00Z', + ends_at: '2026-09-01T10:00:00Z', + start: '2026-09-01', + end: '2026-09-02', + due: '2026-09-01', + lat: 10, + lng: 20, +}; + +const OBJECT_SCHEMA = { + name: OBJECT, + fields: { + name: { type: 'text' }, + owner: { type: 'text' }, + org: { type: 'text' }, + status: { type: 'text' }, + parent_id: { type: 'text' }, + account_id: { type: 'text' }, + invoice: { type: 'text' }, + starts_at: { type: 'datetime' }, + ends_at: { type: 'datetime' }, + start: { type: 'date' }, + end: { type: 'date' }, + due: { type: 'date' }, + lat: { type: 'number' }, + lng: { type: 'number' }, + }, +}; + +/** One query the node issued: which call, and its whole parameter object. */ +interface Query { + via: 'find' | 'aggregate' | 'inline'; + params: Record; +} + +/** A data source (and adapter) that records every query against `OBJECT`. */ +function makeDataSource(opts: { aggregate: boolean }) { + const queries: Query[] = []; + const ds: Record = { + queries, + find: vi.fn(async (object: string, params?: Record) => { + if (object === OBJECT) queries.push({ via: 'find', params: params ?? {} }); + return { data: [{ ...ROW }], total: 1 }; + }), + findOne: vi.fn(async () => ({ ...ROW })), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + batchTransaction: vi.fn(async () => ({ data: [] })), + getObjectSchema: vi.fn(async () => OBJECT_SCHEMA), + }; + if (opts.aggregate) { + ds.aggregate = vi.fn(async (object: string, params?: Record) => { + if (object === OBJECT) queries.push({ via: 'aggregate', params: params ?? {} }); + return [{ count: 1 }]; + }); + } + return ds as Record & { queries: Query[] }; +} + +type Filter = unknown; +type FilterShape = 'tuple' | 'rule'; + +/** The filter in the member shape the node's contract takes. */ +function filterFor(shape: FilterShape, field: string, value: string): Filter { + return shape === 'tuple' ? [[field, '=', value]] : [{ field, operator: 'equals', value }]; +} + +interface NodeRow { + /** Row label: the node type, and the query path where a node has two. */ + node: string; + shape: FilterShape; + /** The node schema carrying `filter` where this node reads it. */ + withFilter: (filter: Filter) => Record; + /** Where the node's query is recorded. */ + path: 'dataSource' | 'inline'; + /** `element:number` aggregates when the adapter can; `false` forces the `find` fallback. */ + aggregate?: boolean; + /** Mounted under a record (the parent a related list scopes to). */ + record?: boolean; + /** Stub the child grid a `list-view` renders, so only the list's own query counts. */ + stubChildGrid?: boolean; + /** Which recorded parameter carries the filter. */ + filterParam: '$filter' | 'filter'; +} + +const top = (base: Record) => (filter: Filter) => ({ ...base, filter }); +const element = (base: { type: string; id: string; properties: Record }) => (filter: Filter) => ({ + ...base, + properties: { ...base.properties, filter }, +}); + +const INLINE = { provider: 'value', items: [{ ...ROW }] }; + +const CALENDAR = { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' }; +const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' }; + +// ⭐ The enumeration. Every census node is a row; the three regression rows +// close the table. +const ROWS: NodeRow[] = [ + { node: 'object-calendar (object)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-calendar', objectName: OBJECT, calendar: CALENDAR }) }, + { node: 'object-calendar (inline)', shape: 'tuple', path: 'inline', filterParam: '$filter', + // `staticData` is the rung that wraps into `{ provider: 'value', items }` on + // this block (objectui#8348); its `data` row is the array arm. + withFilter: top({ type: 'object-calendar', calendar: CALENDAR, staticData: [{ ...ROW }] }) }, + { node: 'object-gantt', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-gantt', objectName: OBJECT, titleField: 'name', startDateField: 'start', endDateField: 'end' }) }, + { node: 'object-kanban', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-kanban', objectName: OBJECT, groupBy: 'status', columns: [{ id: 'open', title: 'Open' }] }) }, + { node: 'object-map (object)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-map', objectName: OBJECT, map: MAP }) }, + { node: 'object-map (inline)', shape: 'tuple', path: 'inline', filterParam: '$filter', + withFilter: top({ type: 'object-map', map: MAP, data: INLINE }) }, + { node: 'object-timeline', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-timeline', objectName: OBJECT, timeline: { titleField: 'name', startDateField: 'due' } }) }, + { node: 'object-tree (object)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-tree', objectName: OBJECT, parentField: 'parent_id', labelField: 'name' }) }, + { node: 'object-tree (inline)', shape: 'tuple', path: 'inline', filterParam: '$filter', + withFilter: top({ type: 'object-tree', parentField: 'parent_id', labelField: 'name', data: INLINE }) }, + { node: 'record:related_list', shape: 'rule', path: 'dataSource', filterParam: '$filter', record: true, + withFilter: top({ type: 'record:related_list', objectName: OBJECT, relationshipField: 'account_id', columns: ['name'] }) }, + { node: 'record:line_items', shape: 'rule', path: 'dataSource', filterParam: '$filter', + withFilter: top({ + type: 'record:line_items', childObject: OBJECT, relationshipField: 'invoice', parentId: 'inv-1', + columns: [{ name: 'name', label: 'Name', type: 'text' }], + }) }, + { node: 'element:repeater', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: element({ type: 'element:repeater', id: 'rep', properties: { object: OBJECT, fields: ['name'] } }) }, + { node: 'element:number (aggregate)', shape: 'tuple', path: 'dataSource', filterParam: 'filter', aggregate: true, + withFilter: element({ type: 'element:number', id: 'num', properties: { object: OBJECT, aggregate: 'count' } }) }, + { node: 'element:number (find fallback)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', aggregate: false, + withFilter: element({ type: 'element:number', id: 'num', properties: { object: OBJECT, aggregate: 'count' } }) }, + { node: 'element:record_picker', shape: 'rule', path: 'dataSource', filterParam: '$filter', + withFilter: element({ type: 'element:record_picker', id: 'rp', properties: { object: OBJECT } }) }, + // Regression rows (objectui#10607, objectui#10666 part 1). + { node: 'object-gallery (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-gallery', objectName: OBJECT, gallery: { titleField: 'name' } }) }, + { node: 'object-grid (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', + withFilter: top({ type: 'object-grid', objectName: OBJECT, columns: ['name'] }) }, + { node: 'list-view (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', stubChildGrid: true, + withFilter: top({ type: 'list-view', objectName: OBJECT, columns: ['name'] }) }, +]; + +type DS = ReturnType; + +interface HostHandle { + rerender: () => void; +} + +/** + * Mounts one node. Every render builds the filter afresh from `makeFilter`, + * so a re-render hands the node an EQUAL filter at a NEW identity; the rest of + * the schema is rebuilt around it the same way on every pass. + */ +const Host = React.forwardRef Filter }>( + function Host({ row, ds, makeFilter }, ref) { + const [, setPass] = React.useState(0); + React.useImperativeHandle(ref, () => ({ rerender: () => setPass((n) => n + 1) }), []); + const node = ; + return ( + + + + {row.record ? ( + + {node} + + ) : node} + + + + ); + }, +); + +/** Let every pending effect and resolved promise land. */ +async function settle() { + await act(async () => { + await new Promise((r) => setTimeout(r, 60)); + }); +} + +let inlineQueries: Query[] = []; + +/** Mount a row and wait for its first query; returns the recorder and the host handle. */ +async function mount(row: NodeRow, makeFilter: () => Filter) { + const ds = makeDataSource({ aggregate: row.aggregate !== false }); + const recorded = () => (row.path === 'inline' ? inlineQueries : ds.queries); + const ref = React.createRef(); + render(); + await waitFor(() => expect(recorded().length, `${row.node} issued no query`).toBeGreaterThan(0)); + await settle(); + return { recorded, ref }; +} + +/** The filter the node sent on its first recorded query. */ +function sentFilter(row: NodeRow, queries: Query[]) { + return queries[0].params[row.filterParam]; +} + +const STUBBED_GRID = () =>
; +let restoreGrid: (() => void) | null = null; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); + inlineQueries = []; + const realFind = ValueDataSource.prototype.find; + vi.spyOn(ValueDataSource.prototype, 'find').mockImplementation(function (this: ValueDataSource, resource, params) { + inlineQueries.push({ via: 'inline', params: (params ?? {}) as Record }); + return realFind.call(this, resource, params); + }); +}); + +afterEach(() => { + cleanup(); + restoreGrid?.(); + restoreGrid = null; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function stubChildGridFor(row: NodeRow) { + if (!row.stubChildGrid) return; + const prev = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', STUBBED_GRID); + restoreGrid = () => { + if (prev) ComponentRegistry.register('object-grid', prev as never); + }; +} + +describe.each(ROWS)('$node — its own filter reaches the query resolved (objectui#10666)', (row) => { + it('sends the signed-in user id for {current_user_id}, and the same query the literal id sends', async () => { + stubChildGridFor(row); + const token = await mount(row, () => filterFor(row.shape, 'owner', '{current_user_id}')); + const sent = JSON.stringify(sentFilter(row, token.recorded())); + expect(sent, `${row.node} sent the literal token`).not.toContain('{current_user_id}'); + expect(sent).toContain(USER); + const tokenQuery = token.recorded()[0]; + cleanup(); + inlineQueries = []; + + const literal = await mount(row, () => filterFor(row.shape, 'owner', USER)); + expect(tokenQuery).toEqual(literal.recorded()[0]); + }); + + it('sends the active organization id for {current_org_id}', async () => { + stubChildGridFor(row); + const { recorded } = await mount(row, () => filterFor(row.shape, 'org', '{current_org_id}')); + const sent = JSON.stringify(sentFilter(row, recorded())); + expect(sent, `${row.node} sent the literal token`).not.toContain('{current_org_id}'); + expect(sent).toContain(ORG); + }); + + it('CONTROL: a token-free filter reaches the query unchanged', async () => { + stubChildGridFor(row); + const authored = filterFor(row.shape, 'owner', 'usr_literal'); + const { recorded } = await mount(row, () => filterFor(row.shape, 'owner', 'usr_literal')); + const sent = sentFilter(row, recorded()); + if (row.shape === 'tuple') { + // A pass-through node: the members go out as authored. + expect(sent).toEqual(authored); + } else { + // A node that composes its own filter with the parent scope, or lowers + // rule objects first: the authored condition is inside what it sends. + expect(JSON.stringify(sent)).toContain('"usr_literal"'); + expect(JSON.stringify(sent)).toContain('"owner"'); + } + }); + + it('re-renders that rebuild an equal filter add no query', async () => { + stubChildGridFor(row); + const { recorded, ref } = await mount(row, () => filterFor(row.shape, 'owner', '{current_user_id}')); + const before = recorded().length; + for (let i = 0; i < 3; i++) { + await act(async () => { + ref.current!.rerender(); + }); + await settle(); + } + expect(recorded().length, `${row.node} re-queried on a re-render that changed nothing`).toBe(before); + }); +}); diff --git a/packages/components/src/renderers/basic/data-list.tsx b/packages/components/src/renderers/basic/data-list.tsx index 4fa38e3b62..5237dc2693 100644 --- a/packages/components/src/renderers/basic/data-list.tsx +++ b/packages/components/src/renderers/basic/data-list.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { useAdapter, useDataInvalidation } from '@object-ui/react'; +import { useAdapter, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; import { cn } from '../../lib/utils'; import { readProps } from './readProps'; @@ -110,7 +110,15 @@ function RepeaterRenderer({ schema }: { schema: any }) { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const filterKey = React.useMemo(() => (props.filter ? JSON.stringify(props.filter) : ''), [props.filter]); + // objectui#10666 — the repeater's own `filter`, with every context token + // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE + // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the + // session scope the host provides, and HELD by structure (`useResolvedFilter` + // in `@object-ui/react`). It sent the literal token on `$filter` before. The + // query and its content key below read THIS, never the raw `props.filter`. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(props.filter, filterScope); + const filterKey = React.useMemo(() => (queryFilter ? JSON.stringify(queryFilter) : ''), [queryFilter]); // objectui#10664 — the sort reaches `$orderby` below, so the fetch effect // keys on it, by CONTENT the way `filterKey` keys the filter: a fresh array // with the same entries is not a change (AGENTS.md #10). @@ -141,7 +149,7 @@ function RepeaterRenderer({ schema }: { schema: any }) { (async () => { try { const query: any = {}; - if (props.filter) query.$filter = props.filter; + if (queryFilter) query.$filter = queryFilter; if (props.sort) query.$orderby = props.sort; if (props.limit) query.$top = props.limit; const res = await adapter.find(props.object, query); diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx index a0611f7c51..71dfadc05a 100644 --- a/packages/components/src/renderers/basic/elements.tsx +++ b/packages/components/src/renderers/basic/elements.tsx @@ -27,7 +27,7 @@ import * as React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ActionDef } from '@object-ui/core'; -import { useAdapter, useAction, useDataInvalidation } from '@object-ui/react'; +import { useAdapter, useAction, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; import { useObjectTranslation, pickLocalized, @@ -389,7 +389,16 @@ function ElementNumberRenderer({ schema }: { schema: any }) { const [value, setValue] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const filterKey = React.useMemo(() => (props.filter ? JSON.stringify(props.filter) : ''), [props.filter]); + // objectui#10666 — the number's own `filter`, with every context token + // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE + // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the + // session scope the host provides, and HELD by structure (`useResolvedFilter` + // in `@object-ui/react`). Both reads below (the `aggregate` filter and the + // `find` fallback's `$filter`) sent the literal token before; they and the + // content key read THIS, never the raw `props.filter`. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(props.filter, filterScope); + const filterKey = React.useMemo(() => (queryFilter ? JSON.stringify(queryFilter) : ''), [queryFilter]); // objectui#10623 — the data-invalidation bus (`notifyDataChanged` from // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a // write to the object this number AGGREGATES is declared, and the effect @@ -412,7 +421,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) { field: props.field, function: props.aggregate, groupBy: '_all', - filter: props.filter, + filter: queryFilter, }); const row = Array.isArray(rows) ? rows[0] : rows; const measureKey = props.aggregate === 'count' ? 'count' : `${props.field ?? ''}_${props.aggregate}`; @@ -425,7 +434,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) { } else if (typeof adapter.find === 'function') { // Last-resort: pull all rows and aggregate client-side. Costly // but matches the chart renderer fallback path. - const res = await adapter.find(props.object, props.filter ? { $filter: props.filter } : undefined); + const res = await adapter.find(props.object, queryFilter ? { $filter: queryFilter } : undefined); // `data` is the ONE rows member `QueryResult` (`@object-ui/types`) // declares; the bare-array arm stays because fakes at this seam // really do answer with a plain array. A `res?.records` arm sat diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index b67a00c205..b5217ead2d 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -39,6 +39,8 @@ import { useAdapter, useElementDataSource, usePageVariableBinding, + useFilterScope, + useResolvedFilter, } from '@object-ui/react'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { I18nLabel } from '@objectstack/spec/ui'; @@ -105,7 +107,15 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { // status panel instead. const unresolved = dataBinding.status === 'loading' || dataBinding.status === 'missing'; const object = unresolved ? undefined : (composed?.object ?? props.object); - const filter = composed?.filter ?? props.filter; + // objectui#10666 — whichever filter wins (the binding's, or the node's own), + // with every context token (`{current_user_id}`, `{current_org_id}`, the date + // macros) resolved ONCE through `@object-ui/core`'s shared + // `resolveFilterPlaceholders`, against the session scope the host provides, + // and HELD by structure (`useResolvedFilter` in `@object-ui/react`). The + // picker sent the literal token on `$filter` before; the query and its + // content key read this value. + const filterScope = useFilterScope(); + const filter = useResolvedFilter(composed?.filter ?? props.filter, filterScope); const sort = composed?.sort ?? props.sort; const limit = composed?.limit ?? props.limit ?? 50; const labelField = props.labelField ?? 'name'; diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 2c804d022f..f6c1b69575 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -37,6 +37,8 @@ import { useSettledSchema, NonGridRowCeilingNote, useDataInvalidation, + useFilterScope, + useResolvedFilter, } from '@object-ui/react'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -600,6 +602,18 @@ export const ObjectCalendar: React.FC = ({ !hasExternalData && dataProvider === 'object' ? schemaObjectName || undefined : undefined, ); + // objectui#10666 — the node's own `filter`, with every context token + // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE + // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the + // session scope the host provides, and HELD by structure (`useResolvedFilter` + // in `@object-ui/react`). A directly authored calendar sent the literal token + // on `$filter` before. Both query paths below (the `object` fetch and the + // inline `ValueDataSource`) and the effect's dependency list read THIS, never + // the raw `schema.filter`, so a re-render that rebuilds an equal filter does + // not re-query. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(schema.filter, filterScope); + // Fetch data based on provider useEffect(() => { // Skip internal fetch when data is managed by a parent component @@ -666,7 +680,7 @@ export const ObjectCalendar: React.FC = ({ // `object` arm below resolves. const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] }); const result = await inlineSource.find('', { - $filter: schema.filter, + $filter: queryFilter, $orderby: convertSortToQueryParams(schema.sort), // The same platform ceiling the `object` arm sends, on the same // probe-row convention (objectui#7210, ruling a′). The ruling's @@ -749,7 +763,7 @@ export const ObjectCalendar: React.FC = ({ ? expandable : expandable.filter((f) => perms.checkField(objectName, f, 'read')); const result = await dataSource.find(objectName, { - $filter: schema.filter, + $filter: queryFilter, $orderby: convertSortToQueryParams(schema.sort), // The platform ceiling (objectui#7210, ruling a′). A calendar // still fetches the whole FILTERED set — it cannot lay out a month @@ -799,7 +813,7 @@ export const ObjectCalendar: React.FC = ({ fetchData(); return () => { isMounted = false; }; }, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData, - schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms, invalidationNonce]); + queryFilter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms, invalidationNonce]); // Transform data to calendar events, and separate out the records that have // no date to be placed on at all (objectui#7071 — see the early return in the diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index ff53ae8861..f040a1f558 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -28,7 +28,7 @@ import { resolveIcon, useIsMobile, } from '@object-ui/components'; -import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useSettledSchema, type RelatedRowActionDef } from '@object-ui/react'; +import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useSettledSchema, useFilterScope, useResolvedFilter, type RelatedRowActionDef } from '@object-ui/react'; import { Plus, ExternalLink, @@ -651,9 +651,20 @@ export const RelatedList: React.FC = ({ // rendered below; it is deliberately NOT collapsed to `undefined`, which // would mean "no filter" and run this list unconstrained — the silent // widening objectui#9001 closed. - const filterKey = JSON.stringify(filter ?? null); + // + // objectui#10666 — the list's own `filter` is lowered AFTER every context + // token in it (`{current_user_id}`, `{current_org_id}`, the date macros) is + // resolved ONCE through `@object-ui/core`'s shared + // `resolveFilterPlaceholders`, against the session scope the host provides, + // and HELD by structure (`useResolvedFilter` in `@object-ui/react`). The list + // merged the literal token into the parent scope before. The content key + // below is taken over the held value, so a new signed-in user moves it and + // re-queries, and a date macro such as `{now}` does not move it every render. + const filterScope = useFilterScope(); + const scopeFilter = useResolvedFilter(filter, filterScope); + const filterKey = JSON.stringify(scopeFilter ?? null); const listFilterResult = React.useMemo( - () => toFilterNodeSafely(filter), + () => toFilterNodeSafely(scopeFilter), // eslint-disable-next-line react-hooks/exhaustive-deps [filterKey], ); diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 44b47677f2..df6919edd7 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -28,7 +28,7 @@ import { } from '@object-ui/components'; import { LineItemsField, type GridColumn } from '@object-ui/fields'; import { createSafeTranslation } from '@object-ui/i18n'; -import { useSchemaContext, useRecordContext } from '@object-ui/react'; +import { useSchemaContext, useRecordContext, useFilterScope, useResolvedFilter } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { buildMasterDetailEditBatch, sumRows } from './masterDetailTx'; import { applyColumnPermissions } from './fieldWriteGate'; @@ -257,10 +257,21 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // refusal is kept as a VALUE and rendered below; collapsing it to // `undefined` would mean "no filter" and load this panel's rows // unconstrained, the silent widening objectui#9001 closed. - const filterKey = JSON.stringify(schema.filter ?? null); + // + // objectui#10666 — the panel's own `filter` is lowered AFTER every context + // token in it (`{current_user_id}`, `{current_org_id}`, the date macros) is + // resolved ONCE through `@object-ui/core`'s shared + // `resolveFilterPlaceholders`, against the session scope the host provides, + // and HELD by structure (`useResolvedFilter` in `@object-ui/react`). The + // panel merged the literal token into the parent scope before. The content + // key is taken over the held value, so a new signed-in user moves it and + // reloads, and a date macro such as `{now}` does not move it every render. + const filterScope = useFilterScope(); + const scopeFilter = useResolvedFilter(schema.filter, filterScope); + const filterKey = JSON.stringify(scopeFilter ?? null); const sortKey = JSON.stringify(schema.sort ?? null); const listFilterResult = useMemo( - () => toFilterNodeSafely(schema.filter), + () => toFilterNodeSafely(scopeFilter), // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on CONTENT, see above [filterKey], ); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 79459f66bb..08438cf4c2 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -37,6 +37,8 @@ import { SchemaRendererContext, NonGridRowCeilingNote, useDataInvalidation, + useFilterScope, + useResolvedFilter, } from '@object-ui/react'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; import { @@ -797,6 +799,16 @@ export const ObjectGantt: React.FC = ({ // Concurrent reloads are sequenced: only the newest request may commit its // result, so a slow earlier response can't clobber a fresher one. const [refreshing, setRefreshing] = useState(false); + // objectui#10666 — the node's own `filter`, with every context token + // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE + // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the + // session scope the host provides, and HELD by structure (`useResolvedFilter` + // in `@object-ui/react`). A directly authored gantt sent the literal token on + // `$filter` before. `reload` and both of its dependency lists read THIS, + // never the raw `schema.filter`, so a re-render that rebuilds an equal filter + // does not re-query. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(schema.filter, filterScope); const reloadSeqRef = useRef(0); /** * Has any reload committed rows yet? (objectui#7237, ruling A′) @@ -908,7 +920,7 @@ export const ObjectGantt: React.FC = ({ ? expandable : expandable.filter((f) => perms.checkField(resource, f, 'read')); const result = await adapter.find(resource, { - $filter: schema.filter, + $filter: queryFilter, $orderby: convertSortToQueryParams(schema.sort), // The platform ceiling (objectui#7210, ruling a′). The gantt still // fetches the whole FILTERED result set — a truthful @@ -992,7 +1004,7 @@ export const ObjectGantt: React.FC = ({ // `ObjectGantt.discardedReloadIdentity-10036.test.tsx` hold the two in // parity by exercising each entry. // eslint-disable-next-line react-hooks/exhaustive-deps -- (rest as any).data intentionally untracked, matching the original effect - }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]); + }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]); /** * Does the query this effect is about to issue DERIVE anything from the @@ -1052,7 +1064,7 @@ export const ObjectGantt: React.FC = ({ useEffect(() => { if (recordQueryDerivesExpand && !objectSchemaReady) return; reloadRef.current(loadedOnceRef.current ? { inPlace: true } : {}); - }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]); + }, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]); /** * objectui#10035 — the refresh input this gantt had none of, so a host could diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 210a3aa3a6..52688002f0 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -18,6 +18,8 @@ import { declaredUserMessage, useSettledSchema, useDataInvalidation, + useFilterScope, + useResolvedFilter, } from '@object-ui/react'; import { NavigationOverlay, @@ -561,6 +563,17 @@ export const ObjectKanban: React.FC = ({ // below re-keys on what it says, never on which array it is. const sortKey = JSON.stringify(schema.sort ?? null); + // objectui#10666 — the node's own `filter`, with every context token + // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE + // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the + // session scope the host provides, and HELD by structure (`useResolvedFilter` + // in `@object-ui/react`). A directly authored board sent the literal token on + // `$filter` before. The fetch below and its dependency list read THIS, never + // the raw `schema.filter`, so a re-render that rebuilds an equal filter does + // not re-query. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(schema.filter, filterScope); + // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a // write to the object this board QUERIES is declared, and the fetch effect @@ -690,7 +703,7 @@ export const ObjectKanban: React.FC = ({ // expression below; the pinned fact — `schema.limit` lowered into // the query's top-level `$top` — is the same one it always held. const query = { - $filter: schema.filter, + $filter: queryFilter, // objectui#10068 — the binding's `dataSource.sort` (or its // view's), delivered here by `ElementDataSourceGate`. Lanes // bucket records in fetch order, so this is also the in-lane @@ -749,7 +762,7 @@ export const ObjectKanban: React.FC = ({ // `ObjectTimeline` spelling: an array rebuilt with the same members must not // refetch the board. // eslint-disable-next-line react-hooks/exhaustive-deps -- `schema.sort` is tracked by CONTENT (sortKey) on purpose; see above - }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, schema.filter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms, invalidationNonce]); + }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, queryFilter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms, invalidationNonce]); // Determine which data to use: external -> bound -> inline -> fetched const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData; diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 014826f63d..dffb7bf592 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -15,14 +15,13 @@ import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@obje import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher'; import { ViewSettingsPopover } from './components/ViewSettingsPopover'; import { UserFilters } from './UserFilters'; -import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope } from '@object-ui/react'; +import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; import type { LoadErrorKind } from '@object-ui/react'; import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; import { detectStatusField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort } from '@object-ui/core'; -import { useResolvedAuthoredFilter } from './useResolvedAuthoredFilter'; import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -1107,11 +1106,11 @@ export const ListView = React.forwardRef(({ // objectui#10607 — the node's own `filter`, with every placeholder resolved // once against the host's session scope and held (see - // `useResolvedAuthoredFilter`). The fetch, the page-reset signature, the + // `useResolvedFilter` in `@object-ui/react`). The fetch, the page-reset signature, the // self-querying views, the child view's node, the export and the empty-state // copy below all read THIS, never the raw `schema.filter`. const filterScope = useFilterScope(); - const authoredFilter = useResolvedAuthoredFilter(schema.filter, filterScope); + const authoredFilter = useResolvedFilter(schema.filter, filterScope); // Convenience: resolve field label with schema.objectName pre-bound const tFieldLabel = React.useCallback( diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index 55346a6dec..f01270a62f 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -7,14 +7,13 @@ */ import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; -import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; import { ComponentRegistry, buildExpandFields, getRecordDisplayName, isEmptyValue } from '@object-ui/core'; import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; import type { DataSource, GalleryConfig, ObjectGallerySchema, QueryParams } from '@object-ui/types'; import { ChevronRight, ChevronDown } from 'lucide-react'; import { getCellRenderer, resolveCellRendererType, readFileValues } from '@object-ui/fields'; -import { useResolvedAuthoredFilter } from './useResolvedAuthoredFilter'; export interface ObjectGalleryProps { /** @@ -417,14 +416,14 @@ export const ObjectGallery: React.FC = (props) => { // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against // the session scope the host provides, and HELD by structure (see - // `useResolvedAuthoredFilter`, the hold `ListView` has used since - // objectui#10607). A directly authored gallery sent the literal token + // `useResolvedFilter` in `@object-ui/react`, the hold `ListView` has used + // since objectui#10607). A directly authored gallery sent the literal token // before; a gallery rendered as a `list-view` child was already handed a // resolved filter, and resolving it again changes nothing. The query and // the fetch effect's dependency list below read THIS, never the raw // `schema.filter`, so the effect does not re-run on every render. const filterScope = useFilterScope(); - const authoredFilter = useResolvedAuthoredFilter(schema.filter, filterScope); + const authoredFilter = useResolvedFilter(schema.filter, filterScope); useEffect(() => { let isMounted = true; diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 3c59cd6219..fbede2de83 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -28,6 +28,8 @@ import { NonGridRowCeilingNote, useDataInvalidation, useSettledSchema, + useFilterScope, + useResolvedFilter, } from '@object-ui/react'; import { NavigationOverlay, cn, useIsMobile } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; @@ -843,6 +845,18 @@ export const ObjectMap: React.FC = ({ */ const fetchSeqRef = useRef(0); + // objectui#10666 — the node's own `filter`, with every context token + // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE + // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the + // session scope the host provides, and HELD by structure (`useResolvedFilter` + // in `@object-ui/react`). A directly authored map sent the literal token on + // `$filter` before. Both query paths below (the `object` fetch and the inline + // `ValueDataSource`), the committed-query key and the effect's dependency + // list read THIS, never the raw `schema.filter`, so a re-render that + // rebuilds an equal filter does not re-query. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(schema.filter, filterScope); + // Fetch data based on provider useEffect(() => { // ⭐ objectui#10664: the object definition GATES the object query; it does @@ -867,7 +881,7 @@ export const ObjectMap: React.FC = ({ // nonce, the readiness flag and `fetchesForItself` (a function of two // members already in it). Compared value for value (`Object.is`), the way // React compares the list itself. - const query = [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema, perms] as const; + const query = [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, queryFilter, schema.sort, objectSchema, perms] as const; const committed = committedQueryRef.current; const answersShownQuery = committed !== null && committed.length === query.length && query.every((v, i) => Object.is(v, committed[i])); const nonceMoved = invalidationNonce !== answeredInvalidationRef.current; @@ -935,7 +949,7 @@ export const ObjectMap: React.FC = ({ // `object` arm below resolves. const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] }); const result = await inlineSource.find('', { - $filter: schema.filter, + $filter: queryFilter, $orderby: convertSortToQueryParams(schema.sort), // The same platform ceiling the `object` arm sends, on the same // probe-row convention (objectui#7210, ruling a′). The ruling's @@ -1014,7 +1028,7 @@ export const ObjectMap: React.FC = ({ ? expandable : expandable.filter((f) => perms.checkField(objectName, f, 'read')); const result = await dataSource.find(objectName, { - $filter: schema.filter, + $filter: queryFilter, $orderby: convertSortToQueryParams(schema.sort), // The platform ceiling (objectui#7210, ruling a′). A map still // fetches the whole FILTERED set — the camera fit is computed from @@ -1055,7 +1069,7 @@ export const ObjectMap: React.FC = ({ return () => { fetchSeqRef.current += 1; }; - }, [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, objectSchemaReady, objectSchema, perms, invalidationNonce, fetchesForItself]); + }, [dataProp, dataProvider, dataObjectName, dataItems, dataSource, hasInlineData, queryFilter, schema.sort, objectSchemaReady, objectSchema, perms, invalidationNonce, fetchesForItself]); // Transform data to map markers const { markers, invalidCount } = useMemo(() => { diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index 7705b9a3df..636a156314 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import type { DataSource, TimelineSchema, ListViewTimelineConfig } from '@object-ui/types'; -import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation } from '@object-ui/react'; +import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; import { extractRecords, buildExpandFields, convertSortToQueryParams, createFieldColorResolver, recordDisplayValueAt } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; @@ -349,7 +349,18 @@ export const ObjectTimeline: React.FC = ({ // (objectstack#7137), and an inline array on a schema node is a NEW object every // render — depending on identity would refetch the whole object on every render. // Same reason `RelatedList` keys its own scope filter on content. - const filterKey = JSON.stringify(schema.filter ?? null); + // + // objectui#10666 — the key is taken over the node's own `filter` with every + // context token (`{current_user_id}`, `{current_org_id}`, the date macros) + // resolved ONCE through `@object-ui/core`'s shared `resolveFilterPlaceholders`, + // against the session scope the host provides, and HELD by structure + // (`useResolvedFilter` in `@object-ui/react`). A directly authored timeline + // sent the literal token on `$filter` before. The query below reads the held + // value, so a new signed-in user moves the key and re-queries, and a date + // macro such as `{now}` does not move it on every render. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(schema.filter, filterScope); + const filterKey = JSON.stringify(queryFilter ?? null); const sortKey = JSON.stringify(schema.sort ?? null); // objectui#10623 — the data-invalidation bus (`notifyDataChanged` from @@ -426,7 +437,7 @@ export const ObjectTimeline: React.FC = ({ // the view it named. `filter` arrives already AND-composed by // `ElementDataSourceGate`, so there is nothing to merge here. const results = await dataSource.find(schema.objectName, { - $filter: schema.filter, + $filter: queryFilter, $orderby: convertSortToQueryParams(schema.sort), $top: resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT), ...(expand.length > 0 ? { $expand: expand } : {}), @@ -486,7 +497,7 @@ export const ObjectTimeline: React.FC = ({ // Have inline / bound items — won't fetch; clear loading. setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- `schema.filter`/`schema.sort` are tracked by CONTENT (filterKey/sortKey) on purpose; see above + // eslint-disable-next-line react-hooks/exhaustive-deps -- `queryFilter`/`schema.sort` are tracked by CONTENT (filterKey/sortKey) on purpose; see above }, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey, objectDefReady, objectDef, filterKey, sortKey, schema.limit, perms, invalidationNonce]); const rawData = (props as any).data || boundData || fetchedData; diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 9bd4058991..643b00ff3f 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -47,6 +47,8 @@ import { useSafeFieldLabel, useSettledSchema, NonGridRowCeilingNote, + useFilterScope, + useResolvedFilter, } from '@object-ui/react'; import { NavigationOverlay, @@ -683,6 +685,18 @@ export const ObjectTree: React.FC = ({ // PR #7428 recorded for `ListView`'s memo and `ObjectCalendar`'s effect). const perms = usePermissions(); + // objectui#10666 — the node's own `filter`, with every context token + // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE + // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the + // session scope the host provides, and HELD by structure (`useResolvedFilter` + // in `@object-ui/react`). A directly authored tree sent the literal token on + // `$filter` before. Both query paths below (the `object` fetch and the inline + // `ValueDataSource`) and the effect's dependency list read THIS, never the + // raw `schema.filter`, so a re-render that rebuilds an equal filter does not + // re-query. + const filterScope = useFilterScope(); + const queryFilter = useResolvedFilter(schema.filter, filterScope); + // Fetch records. useEffect(() => { let cancelled = false; @@ -748,7 +762,7 @@ export const ObjectTree: React.FC = ({ // discriminated union — same narrowing the pre-refactor // `dataConfig.object` read carried. const result = await dataSource.find(dataObjectName as string, { - $filter: schema.filter, + $filter: queryFilter, // The platform ceiling (objectui#7210, ruling a′). A tree still // fetches the whole FILTERED set — a hierarchy assembled from a // page loses every child whose parent fell outside it, which is @@ -832,7 +846,7 @@ export const ObjectTree: React.FC = ({ // array — so this branch needs no object name. const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] }); const result = await inlineSource.find('', { - $filter: schema.filter, + $filter: queryFilter, // The same platform ceiling the `object` arm sends, on the same // probe-row convention (objectui#7210, ruling a′). This is the view // the ceiling's VALUE was measured on — ~5.2 DOM elements per @@ -870,7 +884,7 @@ export const ObjectTree: React.FC = ({ return () => { cancelled = true; }; - }, [dataProvider, dataObjectName, dataItems, dataSource, schema.filter, objectSchema, schemaSettled, (rest as any).data, perms]); + }, [dataProvider, dataObjectName, dataItems, dataSource, queryFilter, objectSchema, schemaSettled, (rest as any).data, perms]); const config = useMemo(() => getTreeConfig(schema), [schema]); const parentField = useMemo( diff --git a/packages/react/README.md b/packages/react/README.md index 0caab1dbef..2794effe30 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -278,6 +278,45 @@ Pass `dataSource: undefined` for a render that should settle immediately with no definition (e.g. a provider that issues no metadata read at all) instead of 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`; +`useResolvedFilter(filter, scope)` resolves the filter through +`@object-ui/core`'s `resolveFilterPlaceholders` and HOLDS the result. + +```tsx +import { useEffect } from 'react' +import { useFilterScope, useResolvedFilter } from '@object-ui/react' +import type { DataSource, QueryParams } from '@object-ui/types' + +function ObjectSomething({ + schema, + dataSource, +}: { + schema: { objectName: string; filter?: QueryParams['$filter'] } + dataSource: DataSource +}) { + const filterScope = useFilterScope() + const filter = useResolvedFilter(schema.filter, filterScope) + + useEffect(() => { + // Query with the held value and key the effect on it, never on schema.filter. + void dataSource.find(schema.objectName, { $filter: filter }) + }, [dataSource, schema.objectName, filter]) +} +``` + +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 +passed in. A token the scope cannot resolve is left in place, and the resolver +logs one warning naming it. + ### NON_GRID_ROW_CEILING The platform's hard row ceiling for a NON-GRID visualisation — gantt, calendar, diff --git a/packages/react/src/hooks/__tests__/useResolvedFilter.test.tsx b/packages/react/src/hooks/__tests__/useResolvedFilter.test.tsx new file mode 100644 index 0000000000..995894b6f4 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useResolvedFilter.test.tsx @@ -0,0 +1,113 @@ +/** + * 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#10666 — `useResolvedFilter`, the ONE hold every data node resolves + * its own authored `filter` through. It resolves with `@object-ui/core`'s + * shared `resolveFilterPlaceholders` and HOLDS the result: the value keeps its + * reference while the authored filter (compared by structure) and the scope's + * three members are unchanged, and is resolved again when any of them moves. + * The node-level pins (every directly authored data node, through the real + * `SchemaRenderer`) are `apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx`. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, cleanup } from '@testing-library/react'; +import type { FilterTokenScope } from '@object-ui/core'; +import { useResolvedFilter } from '../useResolvedFilter'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const SCOPE: FilterTokenScope = { currentUserId: 'usr_42', currentOrgId: 'org_7' }; + +function hold(initial: { filter: unknown; scope: FilterTokenScope }) { + return renderHook(({ filter, scope }) => useResolvedFilter(filter, scope), { initialProps: initial }); +} + +describe('useResolvedFilter (objectui#10666)', () => { + it('resolves {current_user_id} and {current_org_id} against the scope', () => { + const { result } = hold({ + filter: [['owner', '=', '{current_user_id}'], ['org', '=', '{current_org_id}']], + scope: SCOPE, + }); + expect(result.current).toEqual([['owner', '=', 'usr_42'], ['org', '=', 'org_7']]); + }); + + it('keeps the same reference across re-renders with the same filter', () => { + const filter = [['owner', '=', '{current_user_id}']]; + const { result, rerender } = hold({ filter, scope: SCOPE }); + const first = result.current; + rerender({ filter, scope: SCOPE }); + rerender({ filter, scope: { ...SCOPE } }); + expect(result.current).toBe(first); + }); + + it('keeps the same reference when an EQUAL filter is rebuilt on every render', () => { + const { result, rerender } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE }); + const first = result.current; + rerender({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE }); + rerender({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE }); + expect(result.current).toBe(first); + }); + + it('holds a date macro: {now} does not resolve to a new value on every render', () => { + const { result, rerender } = hold({ filter: [['created', '<', '{now}']], scope: SCOPE }); + const first = result.current; + const sent = JSON.stringify(first); + expect(sent).not.toContain('{now}'); + rerender({ filter: [['created', '<', '{now}']], scope: SCOPE }); + expect(result.current).toBe(first); + }); + + it('resolves again when the filter changes by structure', () => { + const { result, rerender } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE }); + const first = result.current; + rerender({ filter: [['owner', '!=', '{current_user_id}']], scope: SCOPE }); + expect(result.current).not.toBe(first); + expect(result.current).toEqual([['owner', '!=', 'usr_42']]); + }); + + it('resolves again when a scope member changes', () => { + const { result, rerender } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: SCOPE }); + rerender({ filter: [['owner', '=', '{current_user_id}']], scope: { ...SCOPE, currentUserId: 'usr_99' } }); + expect(result.current).toEqual([['owner', '=', 'usr_99']]); + rerender({ filter: [['org', '=', '{current_org_id}']], scope: { ...SCOPE, currentOrgId: 'org_8' } }); + expect(result.current).toEqual([['org', '=', 'org_8']]); + }); + + it('CONTROL: a token-free filter comes back as the authored value itself', () => { + const filter = [['owner', '=', 'usr_literal']]; + const { result, rerender } = hold({ filter, scope: SCOPE }); + expect(result.current).toBe(filter); + // An equal rebuild keeps handing out the value first held. + rerender({ filter: [['owner', '=', 'usr_literal']], scope: SCOPE }); + expect(result.current).toBe(filter); + }); + + it('a filter the resolver changes comes back as a new value, not the authored one', () => { + const filter = [['owner', '=', '{current_user_id}']]; + const { result } = hold({ filter, scope: SCOPE }); + expect(result.current).not.toBe(filter); + expect(filter).toEqual([['owner', '=', '{current_user_id}']]); + }); + + it('passes an absent filter through', () => { + const { result } = hold({ filter: undefined, scope: SCOPE }); + expect(result.current).toBeUndefined(); + }); + + it('leaves a token the scope cannot resolve intact, and the shared resolver names it (no fallback)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = hold({ filter: [['owner', '=', '{current_user_id}']], scope: {} }); + expect(result.current).toEqual([['owner', '=', '{current_user_id}']]); + expect(warn.mock.calls.some((c) => String(c[0]).includes('{current_user_id}'))).toBe(true); + }); +}); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 979a1a0d0a..93b35397da 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -9,6 +9,9 @@ export * from './useExpression.js'; // Session scope for filter placeholders ({current_user_id}/{current_org_id}). export * from './useFilterScope.js'; +// The ONE hold a data node resolves its own authored `filter` through +// (objectui#10666), fed by `useFilterScope()`. +export * from './useResolvedFilter.js'; export * from './useActionRunner.js'; export * from './useNavigationOverlay.js'; export * from './usePageVariables.js'; diff --git a/packages/plugin-list/src/useResolvedAuthoredFilter.ts b/packages/react/src/hooks/useResolvedFilter.ts similarity index 52% rename from packages/plugin-list/src/useResolvedAuthoredFilter.ts rename to packages/react/src/hooks/useResolvedFilter.ts index 9d7fa15a41..3723744620 100644 --- a/packages/plugin-list/src/useResolvedAuthoredFilter.ts +++ b/packages/react/src/hooks/useResolvedFilter.ts @@ -7,19 +7,22 @@ */ /** - * The hold that resolves a data node's OWN authored `filter` once, shared by - * the two nodes in this package that query with it: `ListView` - * (objectui#10607) and `ObjectGallery` (objectui#10666). It was local to - * `ListView.tsx` until the gallery needed the same hold; it moved here - * unchanged rather than being copied. Package-internal: `index.tsx` does not - * re-export it, so the package's export set does not move. + * The ONE hold every data node uses to resolve its OWN authored `filter` + * (objectui#10666). PR objectui#10656 wrote it for `list-view` + * (`useResolvedAuthoredFilter`, local to `ListView.tsx`); PR objectui#10705 + * moved it into a module of `plugin-list` for `object-gallery`; the sweep that + * closed objectui#10666 moved it here, beside `useFilterScope`, so the nine + * packages whose nodes send their own filter share one copy instead of growing + * nine. The hold, its key and its comparator moved unchanged; the one addition + * is that a filter the resolver leaves unchanged is handed out as authored (see + * `resolveHeldFilter`). */ import * as React from 'react'; import { resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core'; /** One resolution of the node's own `filter`, remembered with its inputs. */ -interface HeldAuthoredFilter { +interface HeldFilter { authored: unknown; currentUserId: FilterTokenScope['currentUserId']; currentOrgId: FilterTokenScope['currentOrgId']; @@ -27,13 +30,21 @@ interface HeldAuthoredFilter { resolved: unknown; } -function resolveAuthoredFilter(authored: unknown, scope: FilterTokenScope): HeldAuthoredFilter { +function resolveHeldFilter(authored: unknown, scope: FilterTokenScope): HeldFilter { + const resolved = resolveFilterPlaceholders(authored, scope); return { authored, currentUserId: scope.currentUserId, currentOrgId: scope.currentOrgId, onUnresolved: scope.onUnresolved, - resolved: resolveFilterPlaceholders(authored, scope), + // `resolveFilterPlaceholders` copies every array and plain object it walks, + // so a filter with nothing to resolve comes back EQUAL but not the same + // reference. Such a filter is handed out as authored: a node whose filter + // carries no token sends the very value it was given, as it did before it + // resolved anything (the `object-kanban` / `object-calendar` / `object-tree` + // member pins read `$filter` by identity). Only a filter the resolver + // actually changed is handed out as the resolved copy. + resolved: isSameAuthoredFilter(resolved, authored) ? authored : resolved, }; } @@ -49,13 +60,12 @@ function isPlainFilterObject(value: unknown): value is Record { /** * Compare two authored filters by structure, never by serialising them. * - * The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460), - * which this package does not depend on: primitives by `Object.is`, a `Date` - * by its instant, arrays element by element and in order, plain objects by key - * set and value. Anything else is equal only as the same reference, and a - * structure deeper than the bound is "changed". Every uncertainty answers - * "changed", which re-resolves and re-queries; the comparison can drop a - * redundant query, never a needed one. + * The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460): + * primitives by `Object.is`, a `Date` by its instant, arrays element by + * element and in order, plain objects by key set and value. Anything else is + * equal only as the same reference, and a structure deeper than the bound is + * "changed". Every uncertainty answers "changed", which re-resolves and + * re-queries; the comparison can drop a redundant query, never a needed one. */ function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean { if (Object.is(a, b)) return true; @@ -78,31 +88,41 @@ function isSameAuthoredFilter(a: unknown, b: unknown, depth = 0): boolean { } /** - * objectui#10607 — resolve the node's own `filter` ONCE, through + * 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 (`useFilterScope`). Before this, a directly authored - * `list-view` with `filter: [['owner', '=', '{current_user_id}']]` sent the - * literal token on `$filter`, and handed it to the child view it renders. - * `object-view` already resolves the filters it hands a `list-view` host - * (objectui#10506); resolving that value again changes nothing, because a - * resolved id no longer matches the whole-token pattern. + * scope the host provides. Pass `useFilterScope()` as `scope`: + * + * ```tsx + * const filterScope = useFilterScope(); + * const filter = useResolvedFilter(schema.filter, filterScope); + * // query with `filter`, and key the fetch effect on `filter`, never on schema.filter + * ``` * * ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve * is whatever `resolveFilterPlaceholders` makes of it (left intact, with one * warning naming it). * * The result is HELD against its inputs, the shape `plugin-view`'s - * `useResolvedFilterSegments` uses. The fetch effect keys on this filter, so a + * `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a * resolved copy minted on every render would refetch on 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 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 - * value handed out is always the one React committed. + * 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. + * + * A filter the resolver leaves unchanged (no token, or only tokens the scope + * cannot resolve) is handed out as the authored value itself, not as the + * resolver's equal copy, so a token-free filter reaches the query exactly as + * it did before any resolution. */ -export function useResolvedAuthoredFilter(authored: T, scope: FilterTokenScope): T { - const [held, setHeld] = React.useState(() => resolveAuthoredFilter(authored, scope)); +export function useResolvedFilter(authored: T, scope: FilterTokenScope): T { + const [held, setHeld] = React.useState(() => resolveHeldFilter(authored, scope)); if ( held.currentUserId !== scope.currentUserId || held.currentOrgId !== scope.currentOrgId @@ -112,7 +132,7 @@ export function useResolvedAuthoredFilter(authored: T, scope: FilterTokenScop // React's documented "information from previous renders" shape: a set // during render re-renders this component at once, before any child sees // the discarded pass, and the re-render finds the inputs equal. - const next = resolveAuthoredFilter(authored, scope); + const next = resolveHeldFilter(authored, scope); setHeld(next); return next.resolved as T; } diff --git a/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts b/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts index 43925c25e1..e0bc0b8675 100644 --- a/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts +++ b/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts @@ -14,7 +14,8 @@ * declares it (`ComponentPropsMap['object-kanban']`, * `ComponentPropsMap['object-calendar']`); both plugins' registration `inputs` * publish it; both renderers READ it — `plugin-kanban/src/ObjectKanban.tsx` - * lowers `schema.filter` onto `$filter` at its `dataSource.find` call, and + * lowers `schema.filter` onto `$filter` at its `dataSource.find` call (its + * context tokens resolved first, since objectui#10666), and * `plugin-calendar/src/ObjectCalendar.tsx` does the same and additionally * lowers `schema.sort` onto `$orderby` through `convertSortToQueryParams`. The * declaration face of `@object-ui/types` named none of them: an authored value @@ -206,7 +207,11 @@ describe('objectui#8174 — the renderers read these keys, which is what the dec // The positive control: the query that returns a verdict for `sort` below // is the same query that returns `objectName`, so that verdict is a reading. expect(reads.has(READ_CONTROL_KEY)).toBe(true); - expect(readRepo(KANBAN_READER)).toContain('$filter: schema.filter'); + // objectui#10666: the read goes through the context-token hold first, and + // the held value is what reaches `$filter`. + const src = readRepo(KANBAN_READER); + expect(src).toContain('useResolvedFilter(schema.filter, filterScope)'); + expect(src).toContain('$filter: queryFilter'); }); it('…and reads `sort` ONLY as the binding carrier: `dataSource.sort` lowered onto `$orderby` (objectui#10068)', () => { @@ -229,7 +234,9 @@ describe('objectui#8174 — the renderers read these keys, which is what the dec expect(reads.has('sort'), `${CALENDAR_READER} no longer reads schema.sort`).toBe(true); expect(reads.has(READ_CONTROL_KEY)).toBe(true); const src = readRepo(CALENDAR_READER); - expect(src).toContain('$filter: schema.filter'); + // objectui#10666: `filter` reaches `$filter` through the context-token hold. + expect(src).toContain('useResolvedFilter(schema.filter, filterScope)'); + expect(src).toContain('$filter: queryFilter'); expect(src).toContain('$orderby: convertSortToQueryParams(schema.sort)'); }); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 3dfca9c073..06024d7291 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -25,7 +25,7 @@ import type { BaseSchema } from './base.js'; import type { KanbanCard } from './complex.js'; import type { DrillDownConfig } from './data-display.js'; // `QueryParams` is the destination `ObjectGallerySchema.filter`'s own docblock -// names — the value is forwarded verbatim into that slot — so the declaration +// names — the value is forwarded into that slot, context tokens resolved — so the declaration // is an INDEXED ACCESS on it rather than a copy of its arms (objectui#9309). // Type-only: no runtime edge, and `./data.ts` imports nothing from here, so // this adds no cycle in either direction. @@ -2852,7 +2852,10 @@ export interface ObjectMapSchema extends BaseSchema { data?: ViewData; /** Inline records, wrapped into a `{ provider: 'value' }` data config; read SECOND */ staticData?: any[]; - /** Query filter, forwarded verbatim as `$filter` */ + /** + * Query filter, forwarded as `$filter` with its + * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666). + */ filter?: any[]; /** Sort configuration, forwarded as `$orderby`. Array only — the legacy string clause is retired (objectui#8221). */ sort?: SortConfig[]; @@ -2911,14 +2914,15 @@ export interface ObjectTreeSchema extends BaseSchema { /** ObjectQL object name */ objectName: string; /** - * Query filter, forwarded verbatim as `$filter` on the tree's own fetch. + * Query filter, forwarded as `$filter` on the tree's own fetch, with its + * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666). * * Typed as that DESTINATION by an indexed access on {@link QueryParams}, the * shape objectui#9309 settled for {@link ObjectGallerySchema.filter}, so the * declaration cannot drift away from the slot it is forwarded into * (objectui#9549). The key was already delivered and read before it was * declared: `ListView` puts `filter` on the `baseProps` every child view - * receives, and `ObjectTree` sends `$filter: schema.filter`. Until this + * receives, and `ObjectTree` sends it as `$filter`. Until this * declaration the value survived only on `BaseSchema`'s index signature, so * declaring it NARROWS: `filter: 'stage=won'` and `filter: 42` are compile * errors now. @@ -3259,7 +3263,10 @@ export interface ObjectGanttSchema extends BaseSchema { // and `sort` as the two keys the element data-source binding maps onto. /** Inline records, wrapped into a `{ provider: 'value' }` config by `resolveRecordSourceConfig`. */ staticData?: any[]; - /** Query filter (JSON Rules format), forwarded verbatim as `$filter`. */ + /** + * Query filter (JSON Rules format), forwarded as `$filter` with its + * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666). + */ filter?: any[]; /** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. Array only — the legacy string clause is retired (objectui#8221). */ sort?: SortConfig[]; @@ -3472,10 +3479,12 @@ export interface ObjectCalendarSchema extends BaseSchema { */ defaultView?: 'month' | 'week' | 'day'; /** - * Query filter (JSON Rules format), forwarded verbatim as `$filter` on the - * calendar's own fetch — `plugin-calendar/src/ObjectCalendar.tsx` reads - * `schema.filter` at the `dataSource.find` call and again in that effect's - * dependency list. + * Query filter (JSON Rules format), forwarded as `$filter` on the + * calendar's own fetch — `plugin-calendar/src/ObjectCalendar.tsx` holds it + * through `useResolvedFilter` (`@object-ui/react`), with its + * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666), + * and reads the held value at the `dataSource.find` call and again in that + * effect's dependency list. * * Undeclared here until objectui#8174, so an authored value reached the * renderer only through {@link BaseSchema}'s `[key: string]: any` — admitted, @@ -3858,10 +3867,12 @@ export interface ObjectKanbanSchema extends BaseSchema { */ limit?: number; /** - * Query filter (JSON Rules format), forwarded verbatim as `$filter` on the - * board's own fetch — `plugin-kanban/src/ObjectKanban.tsx` reads - * `schema.filter` at the `dataSource.find` call, alongside the `$top` that - * {@link limit} feeds, and again in that effect's dependency list. + * Query filter (JSON Rules format), forwarded as `$filter` on the board's + * own fetch — `plugin-kanban/src/ObjectKanban.tsx` holds it through + * `useResolvedFilter` (`@object-ui/react`), with its + * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666), + * and reads the held value at the `dataSource.find` call, alongside the + * `$top` that {@link limit} feeds, and again in that effect's dependency list. * * Undeclared here until objectui#8174, so an authored value reached the * renderer only through {@link BaseSchema}'s `[key: string]: any` — admitted, @@ -4282,8 +4293,10 @@ export interface ObjectChartSchema extends BaseSchema { /** Dataset measure names */ values?: string[]; /** - * AUTHORABLE — query filter, forwarded verbatim as `$filter` on both query - * legs (`ds.aggregate` and `ds.find`), and conjoined with the click context + * AUTHORABLE — query filter, forwarded as `$filter` on both query legs + * (`ds.aggregate` and `ds.find`) with its context tokens (`{current_user_id}`, + * `{current_org_id}`, the date macros) resolved first through + * `@object-ui/core`'s `resolveFilterPlaceholders`, and conjoined with the click context * to scope a drill-down. * * ⚠️ BOTH shapes, and the union is measured rather than tidied. The array arm @@ -4294,7 +4307,7 @@ export interface ObjectChartSchema extends BaseSchema { * {@link ObjectKanbanSchema.filter} carry. The RECORD arm is what the reads * require: the in-repo corpus authors the ObjectQL object form * (`{ close_date: { $gte, $lte } }`) against fakes that read it that way, and - * both arms travel verbatim to `ds.aggregate` / `ds.find` as `$filter`. + * both arms travel to `ds.aggregate` / `ds.find` as `$filter`, resolved. * Declaring only the array arm would have refused live, working charts. * * ⚠️ Narrowing to ONE arm is a decision LOCAL TO THIS NODE, not a @@ -4579,7 +4592,8 @@ export interface ObjectGallerySchema extends BaseSchema { /** ObjectQL object name; omitted when the records arrive through `bind` or `data` */ objectName?: string; /** - * Query filter, forwarded verbatim as `$filter`. + * Query filter, forwarded as `$filter` with its + * context tokens (`{current_user_id}`, `{current_org_id}`, the date macros) resolved first through `@object-ui/core`'s `resolveFilterPlaceholders` (objectui#10666). * * Typed as the DESTINATION the sentence above names, by an indexed access on * {@link QueryParams} rather than a copy of its arms, so the declaration diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 08a0fe0fe7..69a991aca8 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1827,7 +1827,7 @@ export const ObjectTreeSchema = BaseSchema.extend({ filter: z.union([ z.array(z.any()), z.record(z.string(), z.any()), - ]).optional().describe('Query filter, forwarded verbatim as $filter. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'), + ]).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'), parentField: z.string().optional().describe('Single-parent pointer field (auto-detected when omitted)'), labelField: z.string().optional().describe('Field rendered indented in the first column'), fields: z.array(z.string()).optional().describe('Additional flat columns'), @@ -1972,7 +1972,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // `ObjectGridSchema` — what `ObjectGanttProps.schema` used to be typed as before // objectui#5903 retyped it to `ObjectGanttSchema` — so they need declaring here. staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config — read SECOND by resolveRecordSourceConfig'), - filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'), + filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'), sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), // objectui#10250 — the full-text pair the record query carries, declared in // step with the twin in `../objectql.ts`. ListView's toolbar Search writes @@ -2090,7 +2090,7 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // clause objectui#8221 retired, moves from "parses green here, then silently // dropped by `convertSortToQueryParams` at runtime" to "refused at authoring // time". - filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'), + filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'), sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), }).superRefine(requireRecordSource('object-calendar')); @@ -2298,7 +2298,7 @@ export const ObjectKanbanSchema = BaseSchema.extend({ // top-level `sort`. `ObjectKanban.tsx` reads `schema.sort` only as the // `ElementDataSourceGate` carrier for the binding's `dataSource.sort` // (objectui#10068). Only `ObjectCalendarSchema` above carries both. - filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'), + filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'), // objectui#9606 — the CANONICAL card-title spelling, declared beside the // legacy alias below exactly as `@objectstack/spec` declares the pair on // `ObjectKanbanPropsSchema` (`cardTitle` first, `titleField` as its fallback). @@ -2501,7 +2501,7 @@ export const ObjectChartSchema = BaseSchema.extend({ filter: z.union([ z.array(z.any()), z.record(z.string(), z.any()), - ]).optional().describe('AUTHORABLE — query filter, forwarded verbatim as $filter on both query legs, then spread into the drill-down filter. FilterArray (the spec/react-blocks and registry-inputs spelling) OR the ObjectQL $filter object'), + ]).optional().describe('AUTHORABLE — query filter, forwarded as $filter on both query legs with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first, then spread into the drill-down filter. FilterArray (the spec/react-blocks and registry-inputs spelling) OR the ObjectQL $filter object'), // ⛔ `aggregate` is the SPEC's own schema, never a local near-copy. The first // cut of objectui#7946 spelled it as a local `z.object` with all three members // optional; zod 4 objects are STRIP-postured, so @@ -2638,7 +2638,7 @@ export const ObjectGallerySchema = BaseSchema.extend({ filter: z.union([ z.array(z.any()), z.record(z.string(), z.any()), - ]).optional().describe('Query filter, forwarded verbatim as $filter. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'), + ]).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first. FilterArray (the spec array sugar) OR the ObjectQL $filter object — the two arms of QueryParams[$filter]'), data: z.array(z.record(z.string(), z.unknown())).optional().describe('Inline records'), gallery: stripImportedDefaults(SpecGalleryConfigSchema).optional().describe('Gallery configuration (@objectstack/spec GalleryConfig)'), navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour (drawer/dialog/page)'),