Skip to content
2 changes: 1 addition & 1 deletion .changeset/10506-named-view-context-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ schema handed to `ObjectGrid`, and the `list-view` schema handed to a host's `re
The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve in the same call, in
the browser's local time, as they already did on the surfaces that call the resolver: the
app-shell host, `ObjectChart` and the dashboard widgets. (A directly authored `object-grid` or
`list-view` node still does not call it.) Where they used to reach a backend that resolves them
`list-view` node calls it too, since objectui#10607.) Where they used to reach a backend that resolves them
itself, as the ObjectStack server does in the tenant's configured `localization.timezone` (UTC by
default), a browser whose local day differs from the server's day can now get a different day.

Expand Down
40 changes: 40 additions & 0 deletions .changeset/10607-grid-list-filter-context-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/plugin-grid': patch
'@object-ui/plugin-list': patch
---

A directly authored `object-grid` or `list-view` node resolves the spec's context tokens in its own filter (objectui#10607).

`filter: [['owner', '=', '{current_user_id}']]` on an `object-grid` or `list-view` node authored
straight into a page, not reached through `object-view`, went out on `$filter` as the literal
token: neither package called `@object-ui/core`'s `resolveFilterPlaceholders`. Each now resolves
the node's own filter once through that shared resolver, with the session scope the host provides
through `useFilterScope` (the console shell mounts `FilterScopeProvider` from the signed-in user
and the active organization). On `object-grid` that is `filter` and its deprecated alias
`defaultFilters`: the resolved `filter` feeds the grid's query and its server-side export, and the
resolved alias feeds the query when `filter` is absent. On
`list-view` it is `filter`, and the resolved value feeds the list's query, the child view it
renders, the self-querying views (gantt, tree, chart), the export and the empty-state copy.
`object-view` has done this since objectui#10506; these two nodes, authored directly, had not.

The relative-date macros (`{today}`, `{current_quarter_start}`, …) resolve in the same call, in
the browser's local time, as on the other surfaces that call the resolver. Where they used to
reach a backend that resolves them itself, as the ObjectStack server does in the tenant's
configured `localization.timezone` (UTC by default), a browser whose local day differs from the
server's day can now get a different day.

A token the scope cannot resolve is left as written and the resolver warns once, which is the
resolver's own rule, so the filter is never widened.

The resolved value is held against its inputs: the authored filter, compared by structure, and the
scope's three members (`currentUserId`, `currentOrgId`, `onUnresolved`), each compared on its own.
A re-render that changes none of them does not refetch, including one that rebuilds an equal
filter inline; a new signed-in user refetches with the new id. Date macros are therefore resolved
once and held until the scope or the authored filter changes, and a refetch for another reason
reuses the held value. `object-view` already hands these nodes a filter it has resolved
(objectui#10506); resolving that value again changes nothing, because a resolved id or date no
longer matches the placeholder pattern.

On `object-grid`, the load effect now also refetches when the resolved `defaultFilters` changes (a
new signed-in user, or an in-place edit of the alias). It read that alias without keying on it
before.
134 changes: 129 additions & 5 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react';
import type { ObjectGridSchema, DataSource, ListColumn, TableColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat } from '@object-ui/types';
import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types';
import type { I18nLabel } from '@objectstack/spec/ui';
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions, useDataInvalidation } from '@object-ui/react';
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions, useDataInvalidation, useFilterScope } from '@object-ui/react';
import { createSafeTranslation } from '@object-ui/i18n';
// objectui#8920 — the grid reaches a cell renderer through THIS module and
// nowhere else. `getCellRenderer` / `resolveCellRendererType` are deliberately
Expand Down Expand Up @@ -54,7 +54,7 @@ import {
RefreshIndicator,
} from '@object-ui/components';
import { usePullToRefresh } from '@object-ui/mobile';
import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName } from '@object-ui/core';
import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName, resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import {
RECORD_OVERLAY_DEFAULT_WIDTH,
Expand All @@ -78,6 +78,116 @@ import { BulkActionDialog } from './components/BulkActionDialog';
import type { BulkResult } from './hooks/useBulkExecutor';
import type { BulkActionDef } from '@object-ui/types';

/**
* The node's own authored filter: the canonical `filter`, and its deprecated
* alias `defaultFilters`, which the load effect reads only when `filter` is
* absent.
*/
interface AuthoredGridFilters {
filter: ObjectGridSchema['filter'];
defaultFilters: ObjectGridSchema['defaultFilters'];
}

/** One resolution, remembered with the inputs it was computed from. */
interface HeldGridFilters {
authored: AuthoredGridFilters;
currentUserId: FilterTokenScope['currentUserId'];
currentOrgId: FilterTokenScope['currentOrgId'];
onUnresolved: FilterTokenScope['onUnresolved'];
resolved: AuthoredGridFilters;
}

function resolveGridFilters(authored: AuthoredGridFilters, scope: FilterTokenScope): HeldGridFilters {
return {
authored,
currentUserId: scope.currentUserId,
currentOrgId: scope.currentOrgId,
onUnresolved: scope.onUnresolved,
resolved: resolveFilterPlaceholders(authored, scope),
};
}

/** Depth past which {@link isSameAuthoredFilter} gives up and answers "changed". */
const AUTHORED_FILTER_MAX_DEPTH = 12;

function isPlainFilterObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

/**
* Compare two authored filters by structure, never by serialising them.
*
* The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460),
* which this package cannot import: `plugin-view` depends on `plugin-grid`.
* 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;
if (depth >= AUTHORED_FILTER_MAX_DEPTH) return false;
if (a instanceof Date || b instanceof Date) {
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
return a.every((item, i) => isSameAuthoredFilter(item, b[i], depth + 1));
}
if (isPlainFilterObject(a) && isPlainFilterObject(b)) {
const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) return false;
return keys.every((key) => (
Object.prototype.hasOwnProperty.call(b, key) && isSameAuthoredFilter(a[key], b[key], depth + 1)
));
}
return false;
}

/**
* objectui#10607 — resolve the node's own filter ONCE, through
* `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the session
* scope the host provides (`useFilterScope`). Before this, a directly authored
* `object-grid` with `filter: [['owner', '=', '{current_user_id}']]` sent the
* literal token on `$filter`. `object-view` already resolves the filters it
* hands this grid (objectui#10506); resolving that value again changes
* nothing, because a resolved id no longer matches the whole-token pattern.
*
* ⛔ 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 load effect keys on the filter this
* grid lowers, 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
* filters, compared by structure (a host that rebuilds an equal filter inline
* must not re-query), plus the scope's three members read one by one, never
* the scope object's identity (AGENTS.md #10). The held pair lives in state,
* so the value handed out is always the one React committed.
*/
function useResolvedGridFilters(authored: AuthoredGridFilters, scope: FilterTokenScope): AuthoredGridFilters {
const [held, setHeld] = useState(() => resolveGridFilters(authored, scope));
if (
held.currentUserId !== scope.currentUserId
|| held.currentOrgId !== scope.currentOrgId
|| held.onUnresolved !== scope.onUnresolved
|| !isSameAuthoredFilter(held.authored, authored)
) {
// 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 = resolveGridFilters(authored, scope);
setHeld(next);
return next.resolved;
}
return held.resolved;
}

/**
* A declared `sort` → the `"field order"` join string THIS block sends as
* `$orderby` (objectui#8973).
Expand Down Expand Up @@ -1758,7 +1868,17 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// it. The refusal is kept as a VALUE and rendered by the malformed-filter
// branch below; collapsing it to `undefined` would mean "no filter" and run
// the grid unconstrained, the silent widening objectui#9001 closed.
const schemaFilterSource = schema.filter;
//
// objectui#10607: both authored entries are read RESOLVED and held (see
// `useResolvedGridFilters`), so `{current_user_id}` goes out as the real id
// and an equal filter keeps its reference across renders.
const filterScope = useFilterScope();
const authoredFilters = useResolvedGridFilters(
{ filter: schema.filter, defaultFilters: schema.defaultFilters },
filterScope,
);
const schemaFilterSource = authoredFilters.filter;
const schemaDefaultFilters = authoredFilters.defaultFilters;
const schemaFilterResult = useMemo(() => toFilterNodeSafely(schemaFilterSource), [schemaFilterSource]);
const schemaFilterRefusal = schemaFilterResult.ok ? undefined : schemaFilterResult.refusal;
const schemaFilter = schemaFilterResult.ok ? schemaFilterResult.node : undefined;
Expand Down Expand Up @@ -2164,7 +2284,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// for a `FilterOperatorError` arriving on this path too, which is
// the whole of step 2 for this fourth call site. Converting it to
// `toFilterNodeSafely` here would only rethrow into the same catch.
const legacyFilter = toFilterNode(schema.defaultFilters);
const legacyFilter = toFilterNode(schemaDefaultFilters);
if (legacyFilter !== undefined) {
params.$filter = legacyFilter;
}
Expand Down Expand Up @@ -2357,7 +2477,11 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// reachable a second way.
// `invalidationNonce` (objectui#10035): a write to this object, reported on
// the data-invalidation bus — see its declaration above.
}, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce]);
// `schemaDefaultFilters` (objectui#10607): the deprecated alias, resolved and
// held with `filter`. It changes only when the authored alias changes by
// structure or the scope changes (a new signed-in user), and either one
// changes the `$filter` this effect sends when `filter` is absent.
}, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce]);

// The same reset, for the path the loader above never runs on (objectui#4501
// clause 2). "All N matching are selected" is a claim about ONE query, so it
Expand Down
Loading
Loading