Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/10666-filter-token-sweep.md
Original file line number Diff line number Diff line change
@@ -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.
378 changes: 378 additions & 0 deletions apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions packages/components/src/renderers/basic/data-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -110,7 +110,15 @@ function RepeaterRenderer({ schema }: { schema: any }) {
const [rows, setRows] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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).
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 13 additions & 4 deletions packages/components/src/renderers/basic/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -389,7 +389,16 @@ function ElementNumberRenderer({ schema }: { schema: any }) {
const [value, setValue] = React.useState<number | null>(null);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(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
Expand All @@ -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}`;
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/components/src/renderers/basic/record-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
20 changes: 17 additions & 3 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
useSettledSchema,
NonGridRowCeilingNote,
useDataInvalidation,
useFilterScope,
useResolvedFilter,
} from '@object-ui/react';
import {
RECORD_OVERLAY_DEFAULT_WIDTH,
Expand Down Expand Up @@ -600,6 +602,18 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
!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
Expand Down Expand Up @@ -666,7 +680,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
// `object` arm below resolves.
const inlineSource = new ValueDataSource<any>({ 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
Expand Down Expand Up @@ -749,7 +763,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
? 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
Expand Down Expand Up @@ -799,7 +813,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
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
Expand Down
17 changes: 14 additions & 3 deletions packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -651,9 +651,20 @@ export const RelatedList: React.FC<RelatedListProps> = ({
// 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],
);
Expand Down
17 changes: 14 additions & 3 deletions packages/plugin-form/src/LineItemsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
);
Expand Down
18 changes: 15 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
SchemaRendererContext,
NonGridRowCeilingNote,
useDataInvalidation,
useFilterScope,
useResolvedFilter,
} from '@object-ui/react';
import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n';
import {
Expand Down Expand Up @@ -797,6 +799,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// 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′)
Expand Down Expand Up @@ -908,7 +920,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
? 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
Expand Down Expand Up @@ -992,7 +1004,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// `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
Expand Down Expand Up @@ -1052,7 +1064,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
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
Expand Down
Loading
Loading