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
7 changes: 7 additions & 0 deletions .changeset/10666-gallery-filter-context-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/plugin-list': patch
---

A directly authored `object-gallery` node now resolves the spec's context tokens in its own `filter` (objectui#10666). With `filter: [['owner', '=', '{current_user_id}']]` the gallery used to send the literal token on `$filter`; it now sends the signed-in user's id, and `{current_org_id}` resolves to the active organization. The filter goes through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the scope `FilterScopeProvider` supplies, the same call `list-view` and `object-grid` have made since objectui#10607. The resolved value is held and compared by structure, so re-rendering with an equal filter issues no extra query, and a change of signed-in user queries again. A filter without placeholders reaches the query unchanged. A gallery rendered as a `list-view` child was already handed a resolved filter, and nothing changes for it.

Date macros such as `{today}` in a directly authored gallery's filter are now resolved in the browser's local time, as they already are for `list-view`, `object-grid` and `object-view`. Before this change they reached the server as literals, and the server resolved them in the tenant's time zone.
104 changes: 2 additions & 102 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ 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, resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core';
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
Expand Down Expand Up @@ -691,107 +692,6 @@ export function buildEffectiveFilter(
const WITHHELD_FILTER_GROUP: FilterGroup = { id: 'root', logic: 'and', conditions: [] };
const WITHHELD_USER_FILTER_CONDITIONS: unknown[] = [];

/** One resolution of the node's own `filter`, remembered with its inputs. */
interface HeldAuthoredFilter {
authored: unknown;
currentUserId: FilterTokenScope['currentUserId'];
currentOrgId: FilterTokenScope['currentOrgId'];
onUnresolved: FilterTokenScope['onUnresolved'];
resolved: unknown;
}

function resolveAuthoredFilter(authored: unknown, scope: FilterTokenScope): HeldAuthoredFilter {
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 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.
*/
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
* `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.
*
* ⛔ 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
* 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.
*/
function useResolvedAuthoredFilter<T>(authored: T, scope: FilterTokenScope): T {
const [held, setHeld] = React.useState(() => resolveAuthoredFilter(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 = resolveAuthoredFilter(authored, scope);
setHeld(next);
return next.resolved as T;
}
return held.resolved as T;
}

export function convertFilterGroupToAST(group: FilterGroup): any[] {
if (!group || !group.conditions || group.conditions.length === 0) return [];

Expand Down
26 changes: 21 additions & 5 deletions packages/plugin-list/src/ObjectGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
*/

import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react';
import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation } from '@object-ui/react';
import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope } 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 {
/**
Expand Down Expand Up @@ -412,6 +413,19 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
const fetchesForItself = !!schema.objectName && !boundData && !schema.data && !props.data;
const invalidationNonce = useDataInvalidation(fetchesForItself ? schema.objectName : undefined);

// objectui#10666 — the node's own `filter`, with every placeholder
// (`{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
// 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);

useEffect(() => {
let isMounted = true;

Expand Down Expand Up @@ -478,9 +492,11 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
// verbatim as `$filter`". Typing the adapter above makes
// `find`'s parameter real, so the verbatim forward has to
// name the parameter's own type instead of riding on
// `unknown`. Asserted, not coerced: the value is passed
// through byte-for-byte, exactly as before.
$filter: schema.filter as QueryParams['$filter'],
// `unknown`. Asserted, not coerced. The value is the
// node's filter with its placeholders resolved
// (objectui#10666); a filter that carries none reaches
// the query structurally unchanged.
$filter: authoredFilter as QueryParams['$filter'],
...(expand.length > 0 ? { $expand: expand } : {}),
});

Expand Down Expand Up @@ -549,7 +565,7 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
fetchData();
}
return () => { isMounted = false; };
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDefReady, objectDef, perms, fetchesForItself, invalidationNonce]);
}, [schema.objectName, dataSource, boundData, schema.data, authoredFilter, props.data, objectDefReady, objectDef, perms, fetchesForItself, invalidationNonce]);

const items: Record<string, unknown>[] = props.data || boundData || schema.data || fetchedData || [];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* 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 — a directly authored `object-gallery` node resolves the
* spec's context tokens (`{current_user_id}`, `{current_org_id}`) in its OWN
* filter, through `@object-ui/core`'s ONE shared `resolveFilterPlaceholders`,
* against the session scope the host provides (`FilterScopeProvider` /
* `useFilterScope`). This is the objectui#10607 class on a third host:
* `object-grid` and `list-view` were closed by objectui#10607, and a gallery
* rendered as a `list-view` child already received a resolved filter, but a
* gallery authored straight into a page sent the literal token on `$filter`.
*
* Rendered through the real `SchemaRenderer` and this package's own
* registrations, over a data source that records every `find()`. The
* `list-view` cells are the lit control: the same filter, the same scope, the
* same harness, on a node that already resolves (objectui#10607). Its child
* view is stubbed, so the control reads only the list's own query.
*
* The gallery keys its fetch on the filter, so the resolved value must keep
* its reference while nothing it was computed from has changed: a copy minted
* on every render would refetch on every render. The stability cells count
* `find()` calls across re-renders that change nothing.
*/

import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, waitFor, act, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer, SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react';
import type { DataSource } from '@object-ui/types';
// Registers `list-view` and `object-gallery` (the package's own registrations).
import '../index';

const USER = 'usr_42';
const ORG = 'org_7';

const MINE = [['owner', '=', '{current_user_id}']];

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [{ id: '1', name: 'Acme', owner: USER }], total: 1 })),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async (name: string) => ({
name,
fields: { id: { type: 'text' }, name: { type: 'text' }, owner: { type: 'text' }, org: { type: 'text' } },
})),
};
}

type DS = ReturnType<typeof makeDataSource>;

/** A directly authored `object-gallery` node — no `list-view` or `object-view` above it. */
function galleryNode(over: Record<string, unknown>) {
return { type: 'object-gallery', objectName: 'task', gallery: { titleField: 'name' }, ...over };
}

/** The lit control: a directly authored `list-view` node over the same object. */
function listNode(over: Record<string, unknown>) {
return { type: 'list-view', objectName: 'task', columns: ['name'], ...over };
}

function ui(ds: DS, schema: Record<string, unknown>, user: string | null = USER, org: string | null = ORG) {
return (
<FilterScopeProvider currentUserId={user} currentOrgId={org}>
<SchemaRendererProvider dataSource={ds as unknown as DataSource}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererProvider>
</FilterScopeProvider>
);
}

/** The `$filter` of the Nth `find()` the node issued. */
async function queriedFilter(find: DS['find'], call = 0) {
await waitFor(() => expect(find.mock.calls.length).toBeGreaterThan(call));
return (find.mock.calls[call] as unknown as [string, { $filter?: unknown }])[1]?.$filter;
}

/** Let every pending effect and resolved promise land. */
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 30));
});
}

let prevObjectGrid: ReturnType<typeof ComponentRegistry.get>;
beforeAll(() => {
prevObjectGrid = ComponentRegistry.get('object-grid');
ComponentRegistry.register('object-grid', () => <div data-testid="grid-stub" />);
});
afterAll(() => {
if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid);
else ComponentRegistry.unregister('object-grid');
});

let warn: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warn = vi.spyOn(console, 'warn').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 () => '{}' })));
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
warn.mockRestore();
});

describe('object-gallery — the node’s own filter reaches the query resolved (objectui#10666)', () => {
it('LIT CONTROL: a directly authored list-view sends the signed-in user id for {current_user_id}', async () => {
const ds = makeDataSource();
render(ui(ds, listNode({ filter: MINE })));
expect(await queriedFilter(ds.find)).toEqual([['owner', '=', USER]]);
});

it('sends the signed-in user id for {current_user_id}, not the literal token', async () => {
const ds = makeDataSource();
render(ui(ds, galleryNode({ filter: MINE })));
expect(await queriedFilter(ds.find)).toEqual([['owner', '=', USER]]);
});

it('sends the active organization id for {current_org_id}', async () => {
const ds = makeDataSource();
render(ui(ds, galleryNode({ filter: [['org', '=', '{current_org_id}']] })));
expect(await queriedFilter(ds.find)).toEqual([['org', '=', ORG]]);
});

it('CONTROL: a token-free filter reaches the query unchanged', async () => {
const ds = makeDataSource();
render(ui(ds, galleryNode({ filter: [['owner', '=', 'usr_literal']] })));
expect(await queriedFilter(ds.find)).toEqual([['owner', '=', 'usr_literal']]);
});

it('keeps the token literal with no user in scope, and the shared resolver names it (no fallback)', async () => {
const ds = makeDataSource();
render(ui(ds, galleryNode({ filter: MINE }), null, null));
expect(await queriedFilter(ds.find)).toEqual([['owner', '=', '{current_user_id}']]);
expect(warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('{current_user_id}'))).toBe(true);
});

it('re-queries with the new id when the signed-in user changes', async () => {
const ds = makeDataSource();
const schema = galleryNode({ filter: MINE });
const { rerender } = render(ui(ds, schema));
expect(await queriedFilter(ds.find, 0)).toEqual([['owner', '=', USER]]);
await settle();
const before = ds.find.mock.calls.length;
await act(async () => {
rerender(ui(ds, schema, 'usr_99'));
});
expect(await queriedFilter(ds.find, before)).toEqual([['owner', '=', 'usr_99']]);
});
});

describe('object-gallery — the resolved filter is held, so an equal filter does not refetch (objectui#10666)', () => {
it('issues exactly one query, and none further across re-renders with the SAME node', async () => {
const ds = makeDataSource();
const schema = galleryNode({ filter: MINE });
const { rerender } = render(ui(ds, schema));
await queriedFilter(ds.find);
await settle();
expect(ds.find).toHaveBeenCalledTimes(1);
for (let i = 0; i < 3; i++) {
await act(async () => {
rerender(ui(ds, schema));
});
await settle();
}
expect(ds.find).toHaveBeenCalledTimes(1);
});

it('issues no further query across re-renders that rebuild an EQUAL token filter', async () => {
// A host that writes the node inline hands a fresh, structurally equal
// filter on every render; the hold compares by structure.
const ds = makeDataSource();
const fresh = () => galleryNode({ filter: MINE.map((rule) => [...rule]) });
const { rerender } = render(ui(ds, fresh()));
expect(await queriedFilter(ds.find)).toEqual([['owner', '=', USER]]);
await settle();
const settled = ds.find.mock.calls.length;
for (let i = 0; i < 3; i++) {
await act(async () => {
rerender(ui(ds, fresh()));
});
await settle();
}
expect(ds.find.mock.calls.length).toBe(settled);
});
});
Loading
Loading