From 0094ebf15648dd9e3fef5bc9d49a03a161489d3a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:42:57 +0800 Subject: [PATCH] fix(dashboard,charts): resolve {current_user_id} in widget filters (framework #3574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard widget filtered on `{current_user_id}` rendered `0`. The token reached SQL as a literal, matched no row, and nothing was logged on the client or the server — a silent zero that reads as "you have no work" rather than "this filter did not resolve". The same token in a list-view filter resolved correctly, so a user-scoped list and a user-scoped widget over the same data disagreed. There was no shared resolver. Three ad-hoc implementations had grown up independently — ObjectView for list views, ObjectDataPage for URL filter triples, NavigationRenderer for hrefs — each understanding only the filter shape its own surface used. ObjectView's opened with `if (!Array.isArray(filter)) return filter`, so it could not have been reused by dashboard widgets even in principle: widget filters are MongoDB-style objects. Widgets therefore got no resolution at all — DatasetWidget called resolveDateMacros and nothing else, which is why `{today}` worked in a widget and `{current_user_id}` silently did not. - core: new utils/filter-tokens.ts with resolveContextTokens and resolveFilterPlaceholders. The latter expands EVERY placeholder vocabulary in one call and is what surfaces should use; resolving only some of them is the whole defect. The walk handles arrays and plain objects uniformly, so one resolver covers both platform filter shapes. - react: new FilterScopeProvider / useFilterScope. The renderer packages deliberately do not depend on @object-ui/auth, so the shell supplies the session values. Separate from PredicateScopeContext, which is the expression evaluation scope and carries no organization. - plugin-dashboard / plugin-charts: all six widgets that previously resolved date macros only now resolve both vocabularies — DatasetWidget, ObjectMetricWidget, ObjectDataTable, ObjectPivotTable, and ObjectChart (dataset-bound and inline paths). The chart's compareTo comparison filter gets the session pass too, or the overlay series silently ignores the owner clause the primary series honours. - app-shell: ObjectView's local substituteFilterTokens and ObjectDataPage's inline `=== '{current_user_id}'` ternary now delegate to the shared resolver, so both also gain {current_org_id} and date macros. Two of the three ad-hoc implementations are gone rather than joined by a fourth. An unresolvable token is left intact rather than dropped: leaving it yields an empty result, whereas dropping the clause would WIDEN the result set and show a signed-out viewer everyone's data. It is no longer silent — the resolver warns, naming the token, and suggests the intended spelling for known near-misses. Verified end-to-end against a live app-todo stack: a metric widget filtered on `{ owner: '{current_user_id}' }` read 3 against Total Tasks 8, matching the three records assigned to the signed-in user. Co-Authored-By: Claude --- .changeset/dashboard-filter-context-tokens.md | 56 +++++ packages/app-shell/src/console/AppContent.tsx | 16 +- .../app-shell/src/views/ObjectDataPage.tsx | 16 +- packages/app-shell/src/views/ObjectView.tsx | 48 ++-- packages/core/src/index.ts | 4 + .../src/utils/__tests__/filter-tokens.test.ts | 143 ++++++++++++ packages/core/src/utils/filter-tokens.ts | 209 ++++++++++++++++++ packages/plugin-charts/src/ObjectChart.tsx | 26 ++- .../plugin-dashboard/src/DatasetWidget.tsx | 19 +- .../plugin-dashboard/src/ObjectDataTable.tsx | 13 +- .../src/ObjectMetricWidget.tsx | 18 +- .../plugin-dashboard/src/ObjectPivotTable.tsx | 12 +- .../DatasetWidget.filterTokens.test.tsx | 133 +++++++++++ packages/plugin-dashboard/src/utils.ts | 8 +- packages/react/src/hooks/index.ts | 2 + packages/react/src/hooks/useFilterScope.ts | 71 ++++++ 16 files changed, 731 insertions(+), 63 deletions(-) create mode 100644 .changeset/dashboard-filter-context-tokens.md create mode 100644 packages/core/src/utils/__tests__/filter-tokens.test.ts create mode 100644 packages/core/src/utils/filter-tokens.ts create mode 100644 packages/plugin-dashboard/src/__tests__/DatasetWidget.filterTokens.test.tsx create mode 100644 packages/react/src/hooks/useFilterScope.ts diff --git a/.changeset/dashboard-filter-context-tokens.md b/.changeset/dashboard-filter-context-tokens.md new file mode 100644 index 000000000..a44c150cd --- /dev/null +++ b/.changeset/dashboard-filter-context-tokens.md @@ -0,0 +1,56 @@ +--- +"@object-ui/core": patch +"@object-ui/react": patch +"@object-ui/plugin-dashboard": patch +"@object-ui/plugin-charts": patch +"@object-ui/app-shell": patch +--- + +fix(dashboard,charts): resolve `{current_user_id}` in widget filters (framework #3574) + +A dashboard widget filtered on `{current_user_id}` rendered `0`. The token +reached SQL as a literal, matched no row, and nothing was logged on the client +or the server — a silent zero that reads as "you have no work" rather than +"this filter did not resolve". The same token in a list-view filter resolved +correctly, so a user-scoped list and a user-scoped widget over the same data +disagreed. + +There was no shared resolver. Three ad-hoc implementations had grown up +independently — `ObjectView` for list views, `ObjectDataPage` for URL filter +triples, `NavigationRenderer` for hrefs — and each understood only the filter +shape its own surface used. `ObjectView`'s opened with +`if (!Array.isArray(filter)) return filter`, so it could not have been reused +by dashboard widgets even in principle: widget filters are MongoDB-style +objects. Widgets therefore got no resolution at all — `DatasetWidget` called +`resolveDateMacros` and nothing else, which is why `{today}` worked in a widget +and `{current_user_id}` silently did not. + +- **`@object-ui/core`** — new `utils/filter-tokens.ts` with + `resolveContextTokens` and `resolveFilterPlaceholders`. The latter expands + *every* placeholder vocabulary in one call and is what surfaces should use; + resolving only some of them is the whole defect. The walk handles arrays and + plain objects uniformly, so one resolver covers both platform filter shapes. +- **`@object-ui/react`** — new `FilterScopeProvider` / `useFilterScope`. The + renderer packages deliberately do not depend on `@object-ui/auth`, so the + shell supplies the session values. This is a separate context from + `PredicateScopeContext`, which is the expression evaluation scope and carries + no organization. +- **`@object-ui/plugin-dashboard` / `@object-ui/plugin-charts`** — all six + widgets that previously resolved date macros only now resolve both + vocabularies: `DatasetWidget`, `ObjectMetricWidget`, `ObjectDataTable`, + `ObjectPivotTable`, and `ObjectChart` (dataset-bound and inline paths). The + chart's `compareTo` comparison filter gets the session pass too — otherwise + the overlay series silently ignored the owner clause the primary series + honoured. +- **`@object-ui/app-shell`** — `ObjectView`'s local `substituteFilterTokens` + and `ObjectDataPage`'s inline `=== '{current_user_id}'` ternary now delegate + to the shared resolver, so both also gain `{current_org_id}` and date macros. + Two of the three ad-hoc implementations are gone rather than joined by a + fourth. + +An unresolvable token is left intact rather than dropped: leaving it yields an +empty result, whereas dropping the clause would *widen* the result set and show +a signed-out viewer everyone's data. It is no longer silent — the resolver +warns, naming the token, and suggests the intended spelling for known +near-misses (`{current_user}`, `{user_id}`, `{organization_id}`). Authoring-time +enforcement lands separately as `filter-token-unknown` in `@objectstack/lint`. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index ffed4b060..cf44fa895 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -14,7 +14,7 @@ import { useAssistant } from '../assistant/assistantBus'; import { ModalForm } from '@object-ui/plugin-form'; import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components'; import { toast } from 'sonner'; -import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged } from '@object-ui/react'; +import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider } from '@object-ui/react'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { ConnectionState } from '@object-ui/data-objectstack'; import { useAuth } from '@object-ui/auth'; @@ -114,7 +114,7 @@ function DraftReviewNavigator({ appName }: { appName: string | undefined }) { export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {}) { const [connectionState, setConnectionState] = useState('disconnected'); - const { user, getAuthConfig } = useAuth(); + const { user, getAuthConfig, activeOrganization } = useAuth(); const dataSource = useAdapter(); // Deployment-level feature flags from `/api/v1/auth/config`. Used by @@ -630,6 +630,17 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = return ( + {/* Session scope for `{current_user_id}` / `{current_org_id}` filter + placeholders. Mounted here rather than folded into the expression + scope above: that one is the predicate evaluation context (it carries + no organization), and widening it for filter resolution would couple + two unrelated contracts. Renderer packages deliberately do not depend + on @object-ui/auth, so the shell supplies the values (framework + #3574). */} + )} + ); } diff --git a/packages/app-shell/src/views/ObjectDataPage.tsx b/packages/app-shell/src/views/ObjectDataPage.tsx index 18c3a5ed6..623859a21 100644 --- a/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.tsx @@ -41,6 +41,7 @@ import { Database, Lock, Plus, Save, X } from 'lucide-react'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions, useFieldPermissions } from '@object-ui/permissions'; import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth'; +import { resolveFilterPlaceholders } from '@object-ui/core'; import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState'; import { parseUrlFilterTriples, @@ -78,7 +79,7 @@ export function ObjectDataPage({ dataSource, objects }: any) { const [searchParams, setSearchParams] = useSearchParams(); const { can } = usePermissions(); const { canRead } = useFieldPermissions(objectName ?? ''); - const { user } = useAuth(); + const { user, activeOrganization } = useAuth(); const isAdmin = useIsWorkspaceAdmin(); const metadataClient = useMetadataClient(); // ADR-0037: enter draft-preview after "Save as view" so the fresh draft is @@ -121,11 +122,14 @@ export function ObjectDataPage({ dataSource, objects }: any) { ); } // Template variables mirror nav `recordId` substitution so shared links - // can carry `{current_user_id}`. - return readable.map(([field, op, value]) => - value === '{current_user_id}' ? [field, op, user?.id ?? value] : [field, op, value], - ) as FilterTriple[]; - }, [filterParamsKey, canRead, user?.id]); + // can carry `{current_user_id}`. Routed through the shared resolver so a + // link also gets `{current_org_id}` and date macros, instead of the single + // hard-coded token this used to compare against (framework #3574). + return resolveFilterPlaceholders(readable, { + currentUserId: user?.id ?? null, + currentOrgId: activeOrganization?.id ?? null, + }) as FilterTriple[]; + }, [filterParamsKey, canRead, user?.id, activeOrganization?.id]); // One display chip per field — a date-bucket drill's two range triples // (>= start, < end) collapse into a single "start → end" chip (#1752). diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 2ddcd1345..fb9b6c665 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -11,6 +11,7 @@ import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; +import { resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core'; import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState'; import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage'; const ObjectChart = lazy(() => @@ -86,34 +87,24 @@ const VIEW_TYPE_ICONS: Record> = { const FALLBACK_USER = { id: 'current-user', name: 'Demo User' }; /** - * Replace built-in tokens (e.g. `{current_user_id}`) inside a filter array - * with concrete values. Filters from platform-shipped `listViews` or saved - * `sys_view` rows may declare context-sensitive predicates like + * Replace built-in placeholders (e.g. `{current_user_id}`) inside a list-view + * filter with concrete values. Filters from platform-shipped `listViews` or + * saved `sys_view` rows may declare context-sensitive predicates like * `{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }` * — those need to be substituted before the query reaches the API. * - * Recognised tokens: - * • `{current_user_id}` → the authenticated user's id + * Delegates to the shared `resolveFilterPlaceholders` in `@object-ui/core`, + * which also expands date macros and understands `{current_org_id}`. * - * Returns a deep-cloned copy with substitutions applied. Non-array input - * is returned unchanged. + * This used to be a local implementation that bailed on non-array input + * (`if (!Array.isArray(filter)) return filter`) and knew exactly one token. + * That narrowness is why it could not be reused by dashboard widgets, whose + * filters are MongoDB-style objects — so widgets went without any resolution + * at all and silently rendered 0 (framework #3574). Every surface now routes + * through one shape-agnostic resolver. */ -function substituteFilterTokens(filter: any, currentUserId: string | undefined): any { - if (!Array.isArray(filter)) return filter; - const sub = (v: any): any => { - if (typeof v === 'string') { - if (v === '{current_user_id}') return currentUserId ?? v; - return v; - } - if (Array.isArray(v)) return v.map(sub); - if (v && typeof v === 'object') { - const out: any = {}; - for (const k of Object.keys(v)) out[k] = sub(v[k]); - return out; - } - return v; - }; - return filter.map(sub); +function substituteFilterTokens(filter: any, scope: FilterTokenScope): any { + return resolveFilterPlaceholders(filter, scope); } /** @@ -950,6 +941,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an ? { id: user.id, name: user.name, avatar: user.image } : FALLBACK_USER; + // Session scope for filter placeholders. Memoised so the resolved filter + // identity is stable across renders — it feeds view schemas below. + const filterScope = useMemo( + () => ({ currentUserId: currentUser.id, currentOrgId: activeOrganization?.id ?? null }), + [currentUser.id, activeOrganization?.id], + ); + // Action system for toolbar operations — refreshKey moved up (declared earlier). // Wired to confirmHandler/toastHandler so deletes use the Shadcn AlertDialog // and Sonner toast instead of native window.confirm. @@ -1288,7 +1286,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an sort: (viewDef as any).sort ?? listSchema.sort, filter: (() => { const base = (viewDef as any).filter ?? listSchema.filter; - const substituted = substituteFilterTokens(base, currentUser.id); + const substituted = substituteFilterTokens(base, filterScope); if (!urlFilters.length) return substituted; const baseArr = Array.isArray(substituted) ? substituted : []; return [...baseArr, ...urlFilters]; @@ -1424,7 +1422,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an ...(Array.isArray(viewDef.filter) ? viewDef.filter : []), ...urlFilters, ]; - const substituted = substituteFilterTokens(combined, currentUser.id); + const substituted = substituteFilterTokens(combined, filterScope); return Array.isArray(substituted) && substituted.length ? { filters: substituted } : {}; })()), ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2d172ce8a..192f60d17 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,10 @@ export * from './runtime/capabilities.js'; export { composeStacks } from '@objectstack/spec'; export * from './utils/drill-down.js'; export * from './utils/date-macros.js'; +// Session-scoped filter placeholders ({current_user_id} / {current_org_id}) +// plus `resolveFilterPlaceholders`, the single entry point every surface +// should call so no vocabulary is silently skipped (framework #3574). +export * from './utils/filter-tokens.js'; export * from './utils/dashboard-filters.js'; export * from './utils/merge-filters.js'; export * from './utils/compare-to.js'; diff --git a/packages/core/src/utils/__tests__/filter-tokens.test.ts b/packages/core/src/utils/__tests__/filter-tokens.test.ts new file mode 100644 index 000000000..756ec4a65 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-tokens.test.ts @@ -0,0 +1,143 @@ +/** + * 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, it, expect, vi } from 'vitest'; +import { resolveContextTokens, resolveFilterPlaceholders } from '../filter-tokens'; + +const SCOPE = { currentUserId: 'usr_42', currentOrgId: 'org_7', onUnresolved: null }; + +describe('resolveContextTokens', () => { + // The exact failure from framework #3574: a dashboard widget's filter is a + // MongoDB-style OBJECT, and the predecessor helper bailed on non-arrays — + // so the token reached SQL verbatim and the widget rendered 0. + it('resolves inside an object-shaped widget filter', () => { + expect( + resolveContextTokens({ owner: '{current_user_id}', status: 'open' }, SCOPE), + ).toEqual({ owner: 'usr_42', status: 'open' }); + }); + + it('resolves inside an array-shaped list-view filter', () => { + expect( + resolveContextTokens( + [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }], + SCOPE, + ), + ).toEqual([{ field: 'owner', operator: 'equals', value: 'usr_42' }]); + }); + + it('resolves inside triple-shaped filters', () => { + expect(resolveContextTokens([['owner', '=', '{current_user_id}']], SCOPE)).toEqual([ + ['owner', '=', 'usr_42'], + ]); + }); + + it('reaches nested $and / $or branches and operator objects', () => { + expect( + resolveContextTokens( + { + $and: [ + { status: 'open' }, + { $or: [{ owner: '{current_user_id}' }, { org: '{current_org_id}' }] }, + ], + reviewer: { $in: ['{current_user_id}', 'usr_9'] }, + }, + SCOPE, + ), + ).toEqual({ + $and: [{ status: 'open' }, { $or: [{ owner: 'usr_42' }, { org: 'org_7' }] }], + reviewer: { $in: ['usr_42', 'usr_9'] }, + }); + }); + + it('accepts the ${token} spelling', () => { + expect(resolveContextTokens({ owner: '${current_user_id}' }, SCOPE)).toEqual({ + owner: 'usr_42', + }); + }); + + it('leaves ordinary values, partial braces and non-strings alone', () => { + const input = { + name: 'Acme {Corp}', + note: 'owner is {current_user_id} today', + count: 3, + active: true, + missing: null, + }; + expect(resolveContextTokens(input, SCOPE)).toEqual(input); + }); + + it('passes date macros through untouched — they are a separate vocabulary', () => { + expect(resolveContextTokens({ created_at: { $gte: '{today}' } }, SCOPE)).toEqual({ + created_at: { $gte: '{today}' }, + }); + }); + + it('passes nav-only context-selector ids through silently', () => { + const warn = vi.fn(); + expect( + resolveContextTokens({ pkg: '{active_package}' }, { ...SCOPE, onUnresolved: warn }), + ).toEqual({ pkg: '{active_package}' }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not mutate its input', () => { + const input = { owner: '{current_user_id}' }; + resolveContextTokens(input, SCOPE); + expect(input).toEqual({ owner: '{current_user_id}' }); + }); + + // Leaving the token yields an empty result set. Dropping the clause would + // WIDEN the result set, which is far worse than silently narrowing. + it('leaves a known token intact when scope has no value, and warns', () => { + const warn = vi.fn(); + expect( + resolveContextTokens({ owner: '{current_user_id}' }, { onUnresolved: warn }), + ).toEqual({ owner: '{current_user_id}' }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('current_user_id'); + }); + + it('warns with a suggestion on near-miss spellings', () => { + const warn = vi.fn(); + resolveContextTokens( + { a: '{current_user}', b: '{user_id}', c: '{org_id}' }, + { ...SCOPE, onUnresolved: warn }, + ); + expect(warn).toHaveBeenCalledTimes(3); + expect(warn.mock.calls[0][0]).toContain('{current_user_id}'); + expect(warn.mock.calls[2][0]).toContain('{current_org_id}'); + }); + + it('handles null / undefined filters', () => { + expect(resolveContextTokens(null, SCOPE)).toBeNull(); + expect(resolveContextTokens(undefined, SCOPE)).toBeUndefined(); + }); +}); + +describe('resolveFilterPlaceholders', () => { + // Calling only one resolver is the defect behind #3574 — dashboard widgets + // expanded date macros and silently skipped session tokens. + it('expands BOTH vocabularies in a single call', () => { + const out = resolveFilterPlaceholders( + { owner: '{current_user_id}', created_at: { $gte: '{today}' } }, + SCOPE, + new Date('2026-07-27T12:00:00Z'), + ); + expect(out.owner).toBe('usr_42'); + expect(out.created_at.$gte).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(out.created_at.$gte).not.toBe('{today}'); + }); + + it('is a no-op on filters with no placeholders', () => { + expect(resolveFilterPlaceholders({ status: 'open' }, SCOPE)).toEqual({ status: 'open' }); + }); + + it('handles null / undefined filters', () => { + expect(resolveFilterPlaceholders(undefined, SCOPE)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/utils/filter-tokens.ts b/packages/core/src/utils/filter-tokens.ts new file mode 100644 index 000000000..bc4b17485 --- /dev/null +++ b/packages/core/src/utils/filter-tokens.ts @@ -0,0 +1,209 @@ +/** + * 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 { resolveDateMacros } from './date-macros.js'; + +/** + * Session-scoped filter placeholders — the sibling vocabulary to + * `{date-macros}`. + * + * Filter values travel as JSON, so a user-scoped slice cannot call + * `currentUser().id` inline; it writes a placeholder that this module expands + * immediately before the filter reaches the data source: + * + * { owner_id: '{current_user_id}' } + * + * ## Why this module exists (framework #3574) + * + * There used to be no shared resolver. Three ad-hoc implementations had grown + * up independently — one in `ObjectView` for list views, one in + * `ObjectDataPage` for URL filter triples, one in `NavigationRenderer` for + * hrefs — and each handled only the shape its own surface happened to use. + * Dashboard widgets never got one at all, so `{current_user_id}` in a widget + * filter reached SQL as a literal, matched no row, and the widget rendered + * `0` with no error in the console or the server log. + * + * The failure mode is the point: a metric reading `0` is indistinguishable + * from a metric that is legitimately zero, so the bug survived review and + * shipped. Any *new* surface that resolves placeholders itself will + * eventually reproduce it, which is why resolution lives here, once, and + * every surface calls {@link resolveFilterPlaceholders}. + * + * ## Presentation scope, NOT a security boundary + * + * These tokens scope what a surface *shows*. They do not decide what a caller + * is *allowed* to read — that is RLS, enforced server-side against a + * different vocabulary rooted at `current_user` (`owner_id = current_user.id`). + * Dropping a `{current_user_id}` filter widens a view; it must never widen + * access. Never use a context token as an access control. + * + * ## Contract source + * + * The canonical vocabulary is published as part of the platform contract at + * `@objectstack/spec` → `CONTEXT_TOKENS` / `classifyFilterToken`. It is + * mirrored here — exactly as `date-macros.ts` mirrors `DATE_MACRO_TOKENS` — + * because the installed `@objectstack/spec` predates that export. Keep the two + * in sync; the duplication is temporary until the next coordinated release. + */ + +/** The complete set of session-scoped filter tokens. */ +export const CONTEXT_TOKENS = ['current_user_id', 'current_org_id'] as const; + +export type ContextTokenName = (typeof CONTEXT_TOKENS)[number]; + +/** + * Near-miss spellings → the token the author meant. + * + * Every entry is a real authoring mistake, and each is a correct spelling + * *somewhere else* in the platform, which is exactly why authors reach for it: + * `current_user` is the RLS expression root, `{user_id}` is valid + * `titleFormat` field interpolation, `organization_id` is a real column name. + * + * Mirrors `CONTEXT_TOKEN_SUGGESTIONS` in `@objectstack/spec`. Used only to + * make the runtime warning actionable; the authoring-time gate + * (`validateFilterTokens` in `@objectstack/lint`) is what actually prevents + * these from shipping. + */ +const CONTEXT_TOKEN_SUGGESTIONS: Record = { + current_user: 'current_user_id', + current_user_email: 'current_user_id', + user_id: 'current_user_id', + userid: 'current_user_id', + me: 'current_user_id', + current_organization_id: 'current_org_id', + org_id: 'current_org_id', + organization_id: 'current_org_id', + current_tenant_id: 'current_org_id', +}; + +/** Session values a filter placeholder can resolve against. */ +export interface FilterTokenScope { + /** The signed-in user's id. */ + currentUserId?: string | null; + /** The active organization id. */ + currentOrgId?: string | null; + /** + * Called when a placeholder cannot be resolved — either a recognised token + * with no value in scope (signed out), or a near-miss spelling that resolves + * in no vocabulary. Defaults to a `console.warn`; pass `null` to silence. + */ + onUnresolved?: ((message: string) => void) | null; +} + +/** Whole-string placeholder: `{token}` or `${token}`, anchored. */ +const WHOLE_TOKEN_RE = /^\$?\{([a-zA-Z0-9_]+)\}$/; + +function isContextToken(token: string): token is ContextTokenName { + return (CONTEXT_TOKENS as readonly string[]).includes(token); +} + +/** + * Expand `{current_user_id}` / `{current_org_id}` inside a filter. + * + * Walks arrays and plain objects recursively, which is what makes one + * resolver cover both platform filter shapes: the MongoDB-style object a + * dashboard widget carries (`{ owner_id: '{current_user_id}' }`) and the + * condition/triple arrays a list view carries (`[{ field, operator, value }]` + * / `[['owner','=','…']]`). The predecessor helper in `ObjectView` bailed on + * non-arrays (`if (!Array.isArray(filter)) return filter`), which is why + * lifting it into the dashboard would have been a silent no-op. + * + * Only whole-string placeholders are substituted — an id is an opaque value, + * so embedding it in a larger string (`'user-{current_user_id}'`) is never + * what an author means, and substituting there would silently produce a value + * that matches nothing. Unknown tokens are passed through untouched so that + * date macros and nav-only `AppContextSelector` ids survive this pass. + * + * An unresolvable *known* token is deliberately left as-is rather than + * dropped: leaving it yields an empty result, whereas dropping the clause + * would widen the result set — and a filter that silently widens is far worse + * than one that silently narrows. + */ +export function resolveContextTokens(filter: T, scope: FilterTokenScope = {}): T { + if (filter == null) return filter; + + const { currentUserId, currentOrgId } = scope; + const warn = + scope.onUnresolved === null + ? () => {} + : scope.onUnresolved ?? + ((message: string) => { + // eslint-disable-next-line no-console + console.warn(`[object-ui] ${message}`); + }); + + const values: Record = { + current_user_id: currentUserId, + current_org_id: currentOrgId, + }; + + const walk = (value: any): any => { + if (value == null) return value; + + if (typeof value === 'string') { + const m = value.match(WHOLE_TOKEN_RE); + if (!m) return value; + const token = m[1]; + + if (isContextToken(token)) { + const resolved = values[token]; + if (resolved != null && resolved !== '') return resolved; + warn( + `Filter placeholder "{${token}}" could not be resolved — no ${ + token === 'current_user_id' ? 'signed-in user' : 'active organization' + } in scope. The filter will match no records.`, + ); + return value; + } + + // Not ours. Warn only for near-misses, which resolve in no vocabulary + // at all and would otherwise fail silently; genuine date macros and + // nav context-selector ids must pass through quietly. + const suggestion = CONTEXT_TOKEN_SUGGESTIONS[token.toLowerCase()]; + if (suggestion) { + warn( + `Filter placeholder "{${token}}" is not a recognised token — did you mean ` + + `"{${suggestion}}"? It is sent to the server as a literal string and will ` + + `match no records.`, + ); + } + return value; + } + + if (Array.isArray(value)) return value.map(walk); + if (typeof value === 'object') { + const out: Record = {}; + for (const k of Object.keys(value)) out[k] = walk((value as any)[k]); + return out; + } + return value; + }; + + return walk(filter) as T; +} + +/** + * Resolve **every** placeholder vocabulary a filter value understands — date + * macros and context tokens — in one call. + * + * This is the function surfaces should call. Calling only one of the two + * resolvers is the defect behind framework #3574: dashboard widgets called + * `resolveDateMacros` alone, so `{today}` worked and `{current_user_id}` + * silently did not. + * + * Order is irrelevant (the vocabularies are disjoint) but fixed here so + * behaviour is identical everywhere. + */ +export function resolveFilterPlaceholders( + filter: T, + scope: FilterTokenScope = {}, + now: Date = new Date(), +): T { + if (filter == null) return filter; + return resolveContextTokens(resolveDateMacros(filter, now), scope); +} diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index b6db6a190..4f5539780 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect, useContext, useCallback, useMemo, useRef } from 'react'; -import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope } from '@object-ui/react'; import { ChartRenderer } from './ChartRenderer'; -import { ComponentRegistry, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveDateMacros, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, buildDimensionLabelMap, relabelDimensions, type CompareToConfig, type DrillEvent, type ChartResultField } from '@object-ui/core'; +import { ComponentRegistry, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, buildDimensionLabelMap, relabelDimensions, type CompareToConfig, type DrillEvent, type ChartResultField } from '@object-ui/core'; import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton } from '@object-ui/components'; import { AlertCircle, ArrowUpRight } from 'lucide-react'; import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n'; @@ -405,6 +405,10 @@ export const ObjectChart = (props: any) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [schema.objectName, aggregateKey]); + // Session scope for `{current_user_id}` / `{current_org_id}` in the schema + // filter. Read at component level — `fetchData` is async and a callback. + const filterScope = useFilterScope(); + const fetchData = useCallback(async (ds: any, mounted: { current: boolean }) => { if (!ds || (!schema.objectName && !schema.dataset)) { // No way to fetch — clear loading so the no-datasource / empty state @@ -424,7 +428,7 @@ export const ObjectChart = (props: any) => { // server resolves dimension labels + measure formats, so the legacy // client-side aggregate / groupBy-label resolution below is skipped. if (schema.dataset && typeof ds.queryDataset === 'function') { - const runtimeFilter = resolveDateMacros(schema.filter); + const runtimeFilter = resolveFilterPlaceholders(schema.filter, filterScope); const res = await ds.queryDataset(schema.dataset, { dimensions: Array.isArray(schema.dimensions) ? schema.dimensions : [], measures: Array.isArray(schema.values) ? schema.values : [], @@ -437,17 +441,21 @@ export const ObjectChart = (props: any) => { return; } - // Resolve relative-date macros (e.g. "{current_quarter_start}") - // so both aggregate and find see real ISO dates and any drill-down + // Resolve every filter placeholder — relative-date macros (e.g. + // "{current_quarter_start}") AND session tokens ("{current_user_id}") + // — so both aggregate and find see real values and any drill-down // filter further down the line stays consistent. - const resolvedFilter = resolveDateMacros(schema.filter); + const resolvedFilter = resolveFilterPlaceholders(schema.filter, filterScope); const compareTo: CompareToConfig | undefined = (schema as any).compareTo; const wantsComparison = !!compareTo && supportsCompareTo(schema.chartType); // shiftFilterByCompareTo expects the raw filter (with date macros) // so it can substitute `{current_*}` tokens or re-resolve macros - // against a shifted `now`. + // against a shifted `now`. It only understands the date vocabulary, + // so the session tokens still need their pass over the result — + // otherwise the comparison series silently ignores the owner clause + // that the primary series honours. const comparisonFilter = wantsComparison - ? shiftFilterByCompareTo(schema.filter, compareTo!) + ? resolveContextTokens(shiftFilterByCompareTo(schema.filter, compareTo!), filterScope) : null; const [currentRowsRaw, comparisonRows] = await Promise.all([ @@ -543,7 +551,7 @@ export const ObjectChart = (props: any) => { if (mounted.current) setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, datasetKey, aggregateKey, filterKey, compareToKey, schema.xAxisKey, schema.chartType, runAggregate]); + }, [schema.objectName, datasetKey, aggregateKey, filterKey, compareToKey, schema.xAxisKey, schema.chartType, runAggregate, filterScope]); useEffect(() => { const mounted = { current: true }; diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index cbc775817..e9b7db260 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -44,7 +44,8 @@ import { import { cn, Skeleton, ChartSkeleton, GridSkeleton } from '@object-ui/components'; import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n'; import { BarChart3, AlertTriangle, Download } from 'lucide-react'; -import { resolveDateMacros } from './utils'; +import { useFilterScope } from '@object-ui/react'; +import { resolveFilterPlaceholders } from './utils'; import { DrillDownDrawer } from './DrillDownDrawer'; type Row = Record; @@ -183,15 +184,21 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // ADR-0021 dual-form: the widget's presentation-scope `filter` must flow into // the dataset query as `runtimeFilter`, or a dataset-bound widget renders the // UNFILTERED total (e.g. "open pipeline" showing the grand total). Resolve - // date macros client-side first — exactly as the legacy widget renderers do - // (the server does not expand `{current_quarter_start}` etc.). Keyed on the - // raw filter ref so the resolution is stable across renders. + // placeholders client-side first — exactly as the legacy widget renderers do + // (the server expands neither `{current_quarter_start}` nor + // `{current_user_id}`). Keyed on the raw filter ref so the resolution is + // stable across renders. + // + // Resolve BOTH vocabularies. This used to call `resolveDateMacros` alone, so + // a user-scoped widget sent `{current_user_id}` to SQL as a literal, matched + // no row, and rendered 0 with no error anywhere (framework #3574). + const filterScope = useFilterScope(); const rawFilter = widget?.filter; const runtimeFilter = useMemo( () => (rawFilter && typeof rawFilter === 'object' && Object.keys(rawFilter).length > 0 - ? resolveDateMacros(rawFilter) + ? resolveFilterPlaceholders(rawFilter, filterScope) : undefined), - [rawFilter], + [rawFilter, filterScope], ); const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetTotals[]; error?: string }>({ status: 'idle', rows: [] }); diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index 018783195..91bf68691 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -7,12 +7,12 @@ */ import React, { useState, useEffect, useContext, useMemo, useCallback } from 'react'; -import { useDataScope, SchemaRendererContext, SchemaRenderer } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, SchemaRenderer, useFilterScope } from '@object-ui/react'; import { extractRecords, isDrillEnabled } from '@object-ui/core'; import type { DrillDownConfig } from '@object-ui/types'; import { Skeleton, RefreshIndicator, cn } from '@object-ui/components'; import { useSafeFieldLabel, useObjectTranslation, useLocalization } from '@object-ui/i18n'; -import { resolveDateMacros } from './utils'; +import { resolveFilterPlaceholders } from './utils'; import { buildFieldMeta, renderFieldValue, @@ -174,6 +174,11 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo setDrillRecord(row ?? null); }, []); + // Session scope for `{current_user_id}` / `{current_org_id}` in the schema + // filter. Read at component level — the fetch below is async, and hooks + // cannot be called from inside it. + const filterScope = useFilterScope(); + useEffect(() => { let isMounted = true; @@ -196,7 +201,7 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // cells can render the related record's display name instead of a // bare FK id. Adapters that don't understand `$expand` ignore it. const expand = computeLookupExpand(schema, objectSchema); - const params: any = { $filter: resolveDateMacros(schema.filter) }; + const params: any = { $filter: resolveFilterPlaceholders(schema.filter, filterScope) }; if (expand.length) params.$expand = expand; const results = await dataSource.find(schema.objectName, params); data = extractRecords(results); @@ -226,7 +231,7 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, objectSchema]); + }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, objectSchema, filterScope]); // Fetch object schema for column-header translation and select-option cell labels. useEffect(() => { diff --git a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx index c2fb7cee2..8de0cf821 100644 --- a/packages/plugin-dashboard/src/ObjectMetricWidget.tsx +++ b/packages/plugin-dashboard/src/ObjectMetricWidget.tsx @@ -7,7 +7,7 @@ */ import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'; -import { SchemaRendererContext, SchemaRenderer } from '@object-ui/react'; +import { SchemaRendererContext, SchemaRenderer, useFilterScope } from '@object-ui/react'; import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle } from '@object-ui/components'; import { isDrillEnabled, resolveDrillTitle } from '@object-ui/core'; import type { DrillDownConfig } from '@object-ui/types'; @@ -15,7 +15,7 @@ import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; import { MetricWidget } from './MetricWidget'; import { OpenInListButton } from './OpenInListButton'; import { - resolveDateMacros, + resolveFilterPlaceholders, shiftFilterByCompareTo, compareToTrendLabelKey, computeMetricDelta, @@ -190,10 +190,16 @@ export const ObjectMetricWidget: React.FC = ({ // (e.g. DashboardRenderer.getComponentSchema rebuilds these on every render). const aggregateKey = useMemo(() => (aggregate ? JSON.stringify(aggregate) : ''), [aggregate]); - // Resolve relative-date macros (e.g. "{current_quarter_start}") so the - // server sees a real ISO date and the drill-down `find()` later sees the - // exact same filter as the aggregate query. - const resolvedFilter = useMemo(() => resolveDateMacros(filter), [filter]); + // Resolve every filter placeholder — relative-date macros (e.g. + // "{current_quarter_start}") AND session tokens ("{current_user_id}") — so + // the server sees real values and the drill-down `find()` later sees the + // exact same filter as the aggregate query. Resolving only date macros here + // left user-scoped metrics silently rendering 0 (framework #3574). + const filterScope = useFilterScope(); + const resolvedFilter = useMemo( + () => resolveFilterPlaceholders(filter, filterScope), + [filter, filterScope], + ); const resolvedFilterKey = useMemo( () => (resolvedFilter ? JSON.stringify(resolvedFilter) : ''), [resolvedFilter], diff --git a/packages/plugin-dashboard/src/ObjectPivotTable.tsx b/packages/plugin-dashboard/src/ObjectPivotTable.tsx index 2c560ad58..41af58d08 100644 --- a/packages/plugin-dashboard/src/ObjectPivotTable.tsx +++ b/packages/plugin-dashboard/src/ObjectPivotTable.tsx @@ -7,13 +7,13 @@ */ import React, { useState, useEffect, useContext, useRef } from 'react'; -import { useDataScope, SchemaRendererContext } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, useFilterScope } from '@object-ui/react'; import { useSafeFieldLabel } from '@object-ui/i18n'; import { extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, type DrillEvent } from '@object-ui/core'; import { Skeleton, cn } from '@object-ui/components'; import { PivotTable } from './PivotTable'; import { DrillDownDrawer } from './DrillDownDrawer'; -import { resolveDateMacros } from './utils'; +import { resolveFilterPlaceholders } from './utils'; import type { PivotTableSchema } from '@object-ui/types'; export interface ObjectPivotTableProps { @@ -104,6 +104,10 @@ export const ObjectPivotTable: React.FC = ({ schema, data return () => { alive = false; }; }, [dataSource, schema.objectName]); + // Session scope for `{current_user_id}` / `{current_org_id}` in the schema + // filter. Read at component level — the fetch below is async. + const filterScope = useFilterScope(); + useEffect(() => { let isMounted = true; @@ -118,7 +122,7 @@ export const ObjectPivotTable: React.FC = ({ schema, data if (typeof dataSource.find === 'function') { const results = await dataSource.find(schema.objectName, { - $filter: resolveDateMacros(schema.filter), + $filter: resolveFilterPlaceholders(schema.filter, filterScope), }); data = extractRecords(results); } else { @@ -143,7 +147,7 @@ export const ObjectPivotTable: React.FC = ({ schema, data } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter]); + }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, filterScope]); // Resolve data: bound data > static schema data > fetched data const rawData = boundData || schema.data || fetchedData; diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.filterTokens.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.filterTokens.test.tsx new file mode 100644 index 000000000..a04df2945 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.filterTokens.test.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { FilterScopeProvider } from '@object-ui/react'; +import { DatasetWidget } from '../DatasetWidget'; + +afterEach(cleanup); + +/** + * Regression cover for framework #3574. + * + * A user-scoped dashboard widget sent `{current_user_id}` to the analytics + * service as a literal string. It matched no record, the widget rendered `0`, + * and nothing was logged on the client or the server — a silent zero that + * reads as "you have no work" rather than "this filter did not resolve". + * + * These assert on the `runtimeFilter` actually handed to `queryDataset`, + * because that is the seam the bug lived at: the widget rendered fine, the + * query ran fine, and only the filter payload was wrong. + */ +describe('DatasetWidget — filter placeholder resolution (#3574)', () => { + const makeSource = () => ({ queryDataset: vi.fn(async () => ({ rows: [{ revenue: 7 }] })) }); + + it('resolves {current_user_id} against the mounted filter scope', async () => { + const src = makeSource(); + render( + + + , + ); + + await waitFor(() => + expect(src.queryDataset).toHaveBeenCalledWith('sales', { + dimensions: [], + measures: ['revenue'], + runtimeFilter: { owner: 'usr_42' }, + }), + ); + }); + + it('resolves {current_org_id} too', async () => { + const src = makeSource(); + render( + + + , + ); + + await waitFor(() => + expect(src.queryDataset).toHaveBeenCalledWith('sales', { + dimensions: [], + measures: ['revenue'], + runtimeFilter: { org: 'org_7' }, + }), + ); + }); + + it('resolves session tokens and date macros in the same filter', async () => { + const src = makeSource(); + render( + + + , + ); + + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + const runtimeFilter = (src.queryDataset.mock.calls[0] as any[])[1].runtimeFilter; + expect(runtimeFilter.owner).toBe('usr_42'); + // The date macro must be expanded to a real ISO date, not left as a token. + expect(runtimeFilter.created_at.$gte).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('leaves a non-user-scoped filter untouched', async () => { + const src = makeSource(); + render( + + + , + ); + + await waitFor(() => + expect(src.queryDataset).toHaveBeenCalledWith('sales', { + dimensions: [], + measures: ['revenue'], + runtimeFilter: { stage: { $nin: ['lost'] } }, + }), + ); + }); + + // Signed out / no provider: the token stays intact so the query matches + // nothing. Dropping the clause instead would WIDEN the result set and show + // a signed-out viewer everyone's data — strictly worse than showing none. + it('leaves the token intact when no scope is mounted, never widening the filter', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const src = makeSource(); + render( + , + ); + + await waitFor(() => + expect(src.queryDataset).toHaveBeenCalledWith('sales', { + dimensions: [], + measures: ['revenue'], + runtimeFilter: { owner: '{current_user_id}' }, + }), + ); + // …and it is no longer silent: the unresolved token is reported. + expect(warn).toHaveBeenCalled(); + expect(warn.mock.calls.some((c) => String(c[0]).includes('current_user_id'))).toBe(true); + warn.mockRestore(); + }); +}); diff --git a/packages/plugin-dashboard/src/utils.ts b/packages/plugin-dashboard/src/utils.ts index 697c6b83d..67d7e5a2c 100644 --- a/packages/plugin-dashboard/src/utils.ts +++ b/packages/plugin-dashboard/src/utils.ts @@ -18,7 +18,13 @@ export function isObjectProvider(widgetData: unknown): widgetData is { provider: // Re-export the date-macro resolver from core so existing internal imports // (`import { resolveDateMacros } from './utils'`) keep working. -export { resolveDateMacros } from '@object-ui/core'; +// +// Prefer `resolveFilterPlaceholders` in renderers: it expands BOTH placeholder +// vocabularies (date macros AND `{current_user_id}` / `{current_org_id}`). +// Calling `resolveDateMacros` alone is exactly what left every dashboard +// widget unable to resolve user-scoped filters — they silently rendered 0 +// (framework #3574). +export { resolveDateMacros, resolveFilterPlaceholders } from '@object-ui/core'; // Re-export compareTo helpers from core for convenience. export { diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 0ac91a68c..6b0dc6566 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -7,6 +7,8 @@ */ export * from './useExpression'; +// Session scope for filter placeholders ({current_user_id}/{current_org_id}). +export * from './useFilterScope'; export * from './useActionRunner'; export * from './useNavigationOverlay'; export * from './usePageVariables'; diff --git a/packages/react/src/hooks/useFilterScope.ts b/packages/react/src/hooks/useFilterScope.ts new file mode 100644 index 000000000..36377c7a4 --- /dev/null +++ b/packages/react/src/hooks/useFilterScope.ts @@ -0,0 +1,71 @@ +/** + * 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 { createContext, createElement, useContext, useMemo, type ReactNode } from 'react'; +import type { FilterTokenScope } from '@object-ui/core'; + +/** + * The session values filter placeholders resolve against — `{current_user_id}` + * and `{current_org_id}` (framework #3574). + * + * ## Why a dedicated context + * + * The renderer packages (`plugin-dashboard`, `plugin-charts`, `plugin-list`) + * deliberately do not depend on `@object-ui/auth`; they receive everything + * they need as schema or context. But a widget cannot resolve + * `{current_user_id}` without knowing who is signed in, and the surfaces that + * DO know (the app-shell) sit above them. + * + * `PredicateScopeContext` already carries `current_user` and would have + * covered the user id, but it is the *expression* evaluation scope — it + * carries no organization, and widening it for an unrelated consumer would + * couple filter resolution to predicate semantics. A separate, two-field + * context keeps the contract explicit and lets a non-console host provide + * exactly these values without adopting the expression stack. + * + * When no provider is mounted this returns an empty scope. That is a safe + * default: unresolved tokens are left intact by the resolver, so the filter + * matches nothing rather than silently widening, and the resolver emits one + * warning naming the token. + */ +const FilterScopeContext = createContext({}); + +/** + * Provide the session scope used to resolve filter placeholders. + * + * Mount once, above any surface that renders filtered data. The console shell + * mounts it next to `ExpressionProvider`, where both the signed-in user and + * the active organization are already resolved. + */ +export function FilterScopeProvider({ + currentUserId, + currentOrgId, + children, +}: { + currentUserId?: string | null; + currentOrgId?: string | null; + children: ReactNode; +}) { + const value = useMemo( + () => ({ currentUserId, currentOrgId }), + [currentUserId, currentOrgId], + ); + return createElement(FilterScopeContext.Provider, { value }, children); +} + +/** + * Read the session scope for filter placeholder resolution. + * + * Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from + * `@object-ui/core` — that helper expands every placeholder vocabulary in one + * call, which is the point: resolving only some of them is the defect behind + * #3574. + */ +export function useFilterScope(): FilterTokenScope { + return useContext(FilterScopeContext); +}