Skip to content
53 changes: 53 additions & 0 deletions .changeset/10506-named-view-context-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-view': patch
'@object-ui/core': patch
---

`ObjectView` resolves the spec's context tokens in the filters it hands on (objectui#10506).

A per-user named list view, `{ field: 'owner', operator: 'equals', value: '{current_user_id}' }`,
reached the query with the literal token on the registered `object-view` node: this component
had no read of `@object-ui/core`'s `resolveFilterPlaceholders`, while the app-shell host resolved
the same view.

The three authored filter segments (the active view's filter, `table.filter`,
`table.defaultFilters`) are now resolved 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). All three paths a filter leaves this
component by read the resolved segments: the query a non-grid view issues, the `object-grid`
schema handed to `ObjectGrid`, and the `list-view` schema handed to a host's `renderListView`.
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
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. With no `FilterScopeProvider` mounted, what a
literal `{current_user_id}` then selects depends on the backend: the ObjectStack server resolves
it for a signed-in request and refuses the request otherwise (the REST face answers 401 at its
auth gate before the filter is read, and where a guest context reaches the engine the resolver
answers 400), and a backend with no resolver of its own matches no record.

The resolved value is held against its inputs: the raw segments, compared by structure, and the
scope's three members (`currentUserId`, `currentOrgId`, `onUnresolved`), each compared on its own.
`ObjectGrid` and `ListView` key their fetch on the filter's identity, so a host that re-renders
without changing anything does not trigger a refetch. Date macros are resolved once and held until
the user, the organization or a segment changes, so `{now}` and `{today}` do not produce a new
value on every render of this component: that governs the query a non-grid view issues, the grid,
and a `renderListView` host that queries with the filter it is handed. The console's app-shell
host is not such a host: its `renderListView` resolves the active view's own filter itself
(`viewDef.filter ?? listSchema.filter`, with no filter segment in the schema it gives this
component; the active view's filter still reaches this component through the `views` prop), so
its date macros and its `'current-user'` fallback for a signed-out user are unchanged. A host that resolves the handed value again, as the app-shell's fallback would, changes
nothing: a resolved id or date no longer matches the placeholder pattern. With no user in scope,
this component's pass warns; the app-shell's own pass substitutes `'current-user'` silently, as
before, so the console's query is unchanged and the one difference is that warning.

`@object-ui/core`'s two placeholder walks (`resolveContextTokens` and `resolveDateMacros`, both behind
`resolveFilterPlaceholders`) now return a non-plain object as the same instance instead of rebuilding
it from its own keys, so a `Date` comparand, which the spec admits, no longer comes back as `{}`;
plain and null-prototype objects are still walked. A non-plain object is a leaf whatever it is: a
class instance with own keys carrying a token is also returned as the same instance, no longer
copied and resolved. The contract review of this change found no in-repo caller that passes one.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ A complete object management interface combining grid, form, search, filters, an
}
```

`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the host that renders the list resolves it to the signed-in user's id before the query runs, and resolving it on the `object-view` node path itself is tracked in objectui#10506.
`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that the app-shell host, the charts and the dashboard widgets call, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written and the filter is never widened: the ObjectStack server resolves the literal token for a signed-in request and refuses the request otherwise (the REST face answers 401 at its auth gate before the filter is read, and where a guest context reaches the engine the resolver answers 400), and a backend with no resolver of its own matches no record.

| Property | Type | Description |
|----------|------|-------------|
Expand Down
14 changes: 14 additions & 0 deletions content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,20 @@ then `table.filter`/`table.sort`, then `table.defaultFilters` (filter only —
`sort` has no legacy tier since `table.defaultSort` was retired). If you never
write `listViews`, that first tier never applies.

Whichever tier wins, its filter goes through `resolveFilterPlaceholders` from
`@object-ui/core` before it leaves `ObjectView` (objectui#10506), on all three
paths: the query a non-grid view issues, the grid, and a host's
`renderListView`. So the spec's context tokens (`{current_user_id}`,
`{current_org_id}`) and the relative-date macros (`{today}`,
`{current_quarter_start}`, …) arrive as real values. The user and organization
come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the
console shell mounts; with none mounted a context token is left as written and
the resolver warns. The filter is never widened: the ObjectStack server resolves
the literal token for a signed-in request and refuses the request otherwise
(the REST face answers 401 at its auth gate before the filter is read, and
where a guest context reaches the engine the resolver answers 400), and a
backend with no resolver of its own matches no record.

`pagination` and `selection` have no such tier, and no effect outside the
grid: `defaultViewType: 'kanban' | 'gallery' | 'calendar' | 'timeline' |
'gantt' | 'map'` don't page or multi-select, so `ObjectView` never forwards
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 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.
*/

import { describe, expect, it } from 'vitest';
import { resolveFilterPlaceholders, resolveContextTokens } from '../filter-tokens';
import { resolveDateMacros } from '../date-macros';

/**
* objectui#10506 — the placeholder walks return a NON-plain object as a leaf.
*
* `resolveContextTokens` and `resolveDateMacros` walked every
* `typeof value === 'object'` as a bag and rebuilt it from `Object.keys`. A
* `Date` has no own keys, so a `Date` comparand came back as an empty object:
* `[['due', '>=', someDate]]` resolved to `[['due', '>=', {}]]`, a filter that
* compares against nothing. The spec admits `Date` as a comparand
* (`ACCEPTED_FILTER_COMPARAND_TYPES`; `$gte` and its siblings declare
* `z.date()`), and the resolver's own header promised "arrays and plain
* objects" only.
*
* "Plain" is a prototype of `Object.prototype` or `null`. Both are still
* walked; the two controls below hold that half, so a fix that stopped
* walking objects altogether would go red here too.
*/
describe('objectui#10506 — a Date comparand survives the placeholder walks', () => {
const SCOPE = { currentUserId: 'usr_42', currentOrgId: 'org_7', onUnresolved: null };

it('resolveFilterPlaceholders keeps a Date comparand as the SAME instance', () => {
const due = new Date('2026-01-01T00:00:00Z');
const out = resolveFilterPlaceholders(
[['due', '>=', due], ['owner', '=', '{current_user_id}']],
SCOPE,
) as unknown[][];
expect(out[0][2]).toBe(due);
// The token beside it is still resolved in the same pass.
expect(out[1][2]).toBe('usr_42');
});

it('keeps a Date inside an operator map as the SAME instance', () => {
const due = new Date('2026-01-01T00:00:00Z');
const out = resolveFilterPlaceholders({ due: { $gte: due }, owner: '{current_user_id}' }, SCOPE) as {
due: { $gte: unknown };
owner: unknown;
};
expect(out.due.$gte).toBe(due);
expect(out.owner).toBe('usr_42');
});

it('each walk on its own returns the Date untouched (both hunks are needed)', () => {
const due = new Date('2026-01-01T00:00:00Z');
expect((resolveDateMacros([['due', '>=', due]]) as unknown[][])[0][2]).toBe(due);
expect((resolveContextTokens([['due', '>=', due]], SCOPE) as unknown[][])[0][2]).toBe(due);
});

it('CONTROL: a plain object carrying a token is still walked and resolved', () => {
expect(resolveFilterPlaceholders({ owner: '{current_user_id}', org: '{current_org_id}' }, SCOPE)).toEqual({
owner: 'usr_42',
org: 'org_7',
});
});

it('CONTROL: a null-prototype object carrying a token is still walked and resolved', () => {
const bag = Object.assign(Object.create(null) as Record<string, unknown>, {
owner: '{current_user_id}',
nested: Object.assign(Object.create(null) as Record<string, unknown>, { org: '{current_org_id}' }),
});
const out = resolveFilterPlaceholders(bag, SCOPE) as Record<string, any>;
expect(out.owner).toBe('usr_42');
expect(out.nested.org).toBe('org_7');
});
});
6 changes: 6 additions & 0 deletions packages/core/src/utils/date-macros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,13 @@ export function resolveDateMacros<T = any>(filter: T, now: Date = new Date()): T
return touched ? replaced : value;
}
if (Array.isArray(value)) return value.map(walk);
// Only a PLAIN object (prototype `Object.prototype` or `null`) is walked;
// any other object — a `Date` comparand above all — is returned as the
// same instance rather than rebuilt from its own keys into `{}`
// (objectui#10506).
if (typeof value === 'object') {
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return value;
const out: Record<string, any> = {};
for (const k of Object.keys(value)) out[k] = walk((value as any)[k]);
return out;
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/utils/filter-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,13 @@ export function resolveContextTokens<T = any>(filter: T, scope: FilterTokenScope
}

if (Array.isArray(value)) return value.map(walk);
// Only a PLAIN object (prototype `Object.prototype` or `null`) is a bag to
// walk. Anything else is a leaf, returned as the same instance: rebuilding
// a `Date` from its own keys — it has none — turned a comparand the spec
// admits (`ACCEPTED_FILTER_COMPARAND_TYPES`) into `{}` (objectui#10506).
if (typeof value === 'object') {
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return value;
const out: Record<string, any> = {};
for (const k of Object.keys(value)) out[k] = walk((value as any)[k]);
return out;
Expand Down
19 changes: 18 additions & 1 deletion packages/plugin-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,24 @@ predates this change: an **active named view's own** `filter` / `sort`
`table`. In order, highest first: the active named view's `filter`/`sort`,
then `table.filter`/`table.sort`, then `table.defaultFilters` (filter only —
`sort` has no legacy tier since `table.defaultSort` was retired). (If you
never write `listViews`, that first tier never applies.) `pagination` and `selection` have no such tier, and no effect
never write `listViews`, that first tier never applies.)

Whichever tier wins, its filter goes through `resolveFilterPlaceholders` from
`@object-ui/core` before it leaves `ObjectView` (objectui#10506), on all three
paths: the query a non-grid view issues, the grid, and a host's
`renderListView`. So the spec's context tokens (`{current_user_id}`,
`{current_org_id}`) and the relative-date macros (`{today}`,
`{current_quarter_start}`, …) arrive as real values. The user and organization
come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the
console shell mounts; with none mounted a context token is left as written and
the resolver warns. The filter is never widened: the ObjectStack server resolves
the literal token for a signed-in request and refuses the request otherwise
(the REST face answers 401 at its auth gate before the filter is read, and
where a guest context reaches the engine the resolver answers 400), and a
backend with no resolver of its own matches no record.
`src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx` pins each path.

`pagination` and `selection` have no such tier, and no effect
outside the grid — the non-grid renderers don't page or multi-select, so
`ObjectView` never forwards either spelling to them.

Expand Down
Loading
Loading