From 5ae7e1159d707e13e54f39e408e6c1e37c3356cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:06:38 +0000 Subject: [PATCH 1/3] test(console): pin context-token resolution on every directly authored data node (objectui#10666) One enumeration table: the eleven census nodes (calendar, map and tree on both query paths; number on both the aggregate and the find fallback) plus object-gallery, object-grid and list-view as regression rows. Each row renders the node through the real SchemaRenderer and its package registration, under FilterScopeProvider, over a recording data source. Committed RED against the unfixed nodes: 38 failed, 34 passed. Co-Authored-By: Claude --- .../filterContextTokensSweep-10666.test.tsx | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx 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); + }); +}); From 69d68598df61d61a194a056ab8c0ed8548404e9b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:13:45 +0000 Subject: [PATCH 2/3] fix: resolve context tokens in every directly authored data node's own filter (objectui#10666) @object-ui/react exports useResolvedFilter beside useFilterScope: the hold plugin-list used privately (useResolvedAuthoredFilter), moved here so one copy serves every node. plugin-list's module is deleted and its two call sites import the shared hook. The one addition: a filter the resolver leaves unchanged is handed out as authored, so a token-free filter reaches the query by identity, as before. The eleven census nodes read their own filter through the hold, and feed the query and the fetch effect's key from the held value: object-calendar, object-map and object-tree (object and inline paths), object-gantt, object-kanban, object-timeline, record:related_list, record:line_items, and the repeater, number and record_picker elements. @object-ui/types: the filter docblocks that said "forwarded verbatim as $filter" now say context tokens are resolved first. Prose only. Co-Authored-By: Claude --- .changeset/10666-filter-token-sweep.md | 33 +++++ .../src/renderers/basic/data-list.tsx | 14 ++- .../src/renderers/basic/elements.tsx | 17 ++- .../src/renderers/basic/record-picker.tsx | 12 +- .../plugin-calendar/src/ObjectCalendar.tsx | 20 +++- packages/plugin-detail/src/RelatedList.tsx | 17 ++- packages/plugin-form/src/LineItemsPanel.tsx | 17 ++- packages/plugin-gantt/src/ObjectGantt.tsx | 18 ++- packages/plugin-kanban/src/ObjectKanban.tsx | 17 ++- packages/plugin-list/src/ListView.tsx | 7 +- packages/plugin-list/src/ObjectGallery.tsx | 9 +- packages/plugin-map/src/ObjectMap.tsx | 22 +++- .../plugin-timeline/src/ObjectTimeline.tsx | 19 ++- packages/plugin-tree/src/ObjectTree.tsx | 20 +++- packages/react/README.md | 39 ++++++ .../__tests__/useResolvedFilter.test.tsx | 113 ++++++++++++++++++ packages/react/src/hooks/index.ts | 3 + .../src/hooks/useResolvedFilter.ts} | 76 +++++++----- packages/types/src/objectql.ts | 48 +++++--- packages/types/src/zod/objectql.zod.ts | 12 +- 20 files changed, 440 insertions(+), 93 deletions(-) create mode 100644 .changeset/10666-filter-token-sweep.md create mode 100644 packages/react/src/hooks/__tests__/useResolvedFilter.test.tsx rename packages/{plugin-list/src/useResolvedAuthoredFilter.ts => react/src/hooks/useResolvedFilter.ts} (52%) 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/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 77400ebf4d..fbb60b96d4 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -36,6 +36,8 @@ import { useSettledSchema, NonGridRowCeilingNote, useDataInvalidation, + useFilterScope, + useResolvedFilter, } from '@object-ui/react'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -592,6 +594,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 @@ -658,7 +672,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 @@ -741,7 +755,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 @@ -791,7 +805,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 0c32f53475..5e9a555424 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, type RelatedRowActionDef } from '@object-ui/react'; +import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useFilterScope, useResolvedFilter, type RelatedRowActionDef } from '@object-ui/react'; import { Plus, ExternalLink, @@ -629,9 +629,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 337ad8e898..8e057e1321 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'; @@ -345,7 +345,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 @@ -422,7 +433,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 } : {}), @@ -471,7 +482,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/objectql.ts b/packages/types/src/objectql.ts index 1dedf27680..f3d0223446 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 a1874841e7..ccf58ebf14 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)'), From b4b633c7f5cbb68cf6f882d3349bed5ded9a1367 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:07:09 +0000 Subject: [PATCH 3/3] test(types): follow the kanban and calendar filter read through the context-token hold (objectui#10666) The objectui#8174 source pin looked for the literal `$filter: schema.filter`. Both renderers now read `schema.filter` through useResolvedFilter and send the held value, so the pin reads both halves of that chain. Co-Authored-By: Claude --- .../kanban-calendar-filter-sort-8174.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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)'); });