Skip to content

Commit 7b35e4b

Browse files
os-zhuangclaude
andauthored
fix(dashboard,charts): resolve {current_user_id} in widget filters (framework #3574) (#2857)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91f93cb commit 7b35e4b

16 files changed

Lines changed: 731 additions & 63 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@object-ui/core": patch
3+
"@object-ui/react": patch
4+
"@object-ui/plugin-dashboard": patch
5+
"@object-ui/plugin-charts": patch
6+
"@object-ui/app-shell": patch
7+
---
8+
9+
fix(dashboard,charts): resolve `{current_user_id}` in widget filters (framework #3574)
10+
11+
A dashboard widget filtered on `{current_user_id}` rendered `0`. The token
12+
reached SQL as a literal, matched no row, and nothing was logged on the client
13+
or the server — a silent zero that reads as "you have no work" rather than
14+
"this filter did not resolve". The same token in a list-view filter resolved
15+
correctly, so a user-scoped list and a user-scoped widget over the same data
16+
disagreed.
17+
18+
There was no shared resolver. Three ad-hoc implementations had grown up
19+
independently — `ObjectView` for list views, `ObjectDataPage` for URL filter
20+
triples, `NavigationRenderer` for hrefs — and each understood only the filter
21+
shape its own surface used. `ObjectView`'s opened with
22+
`if (!Array.isArray(filter)) return filter`, so it could not have been reused
23+
by dashboard widgets even in principle: widget filters are MongoDB-style
24+
objects. Widgets therefore got no resolution at all — `DatasetWidget` called
25+
`resolveDateMacros` and nothing else, which is why `{today}` worked in a widget
26+
and `{current_user_id}` silently did not.
27+
28+
- **`@object-ui/core`** — new `utils/filter-tokens.ts` with
29+
`resolveContextTokens` and `resolveFilterPlaceholders`. The latter expands
30+
*every* placeholder vocabulary in one call and is what surfaces should use;
31+
resolving only some of them is the whole defect. The walk handles arrays and
32+
plain objects uniformly, so one resolver covers both platform filter shapes.
33+
- **`@object-ui/react`** — new `FilterScopeProvider` / `useFilterScope`. The
34+
renderer packages deliberately do not depend on `@object-ui/auth`, so the
35+
shell supplies the session values. This is a separate context from
36+
`PredicateScopeContext`, which is the expression evaluation scope and carries
37+
no organization.
38+
- **`@object-ui/plugin-dashboard` / `@object-ui/plugin-charts`** — all six
39+
widgets that previously resolved date macros only now resolve both
40+
vocabularies: `DatasetWidget`, `ObjectMetricWidget`, `ObjectDataTable`,
41+
`ObjectPivotTable`, and `ObjectChart` (dataset-bound and inline paths). The
42+
chart's `compareTo` comparison filter gets the session pass too — otherwise
43+
the overlay series silently ignored the owner clause the primary series
44+
honoured.
45+
- **`@object-ui/app-shell`**`ObjectView`'s local `substituteFilterTokens`
46+
and `ObjectDataPage`'s inline `=== '{current_user_id}'` ternary now delegate
47+
to the shared resolver, so both also gain `{current_org_id}` and date macros.
48+
Two of the three ad-hoc implementations are gone rather than joined by a
49+
fourth.
50+
51+
An unresolvable token is left intact rather than dropped: leaving it yields an
52+
empty result, whereas dropping the clause would *widen* the result set and show
53+
a signed-out viewer everyone's data. It is no longer silent — the resolver
54+
warns, naming the token, and suggests the intended spelling for known
55+
near-misses (`{current_user}`, `{user_id}`, `{organization_id}`). Authoring-time
56+
enforcement lands separately as `filter-token-unknown` in `@objectstack/lint`.

packages/app-shell/src/console/AppContent.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useAssistant } from '../assistant/assistantBus';
1414
import { ModalForm } from '@object-ui/plugin-form';
1515
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
1616
import { toast } from 'sonner';
17-
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged } from '@object-ui/react';
17+
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider } from '@object-ui/react';
1818
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
1919
import type { ConnectionState } from '@object-ui/data-objectstack';
2020
import { useAuth } from '@object-ui/auth';
@@ -114,7 +114,7 @@ function DraftReviewNavigator({ appName }: { appName: string | undefined }) {
114114

115115
export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {}) {
116116
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
117-
const { user, getAuthConfig } = useAuth();
117+
const { user, getAuthConfig, activeOrganization } = useAuth();
118118
const dataSource = useAdapter();
119119

120120
// Deployment-level feature flags from `/api/v1/auth/config`. Used by
@@ -630,6 +630,17 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
630630

631631
return (
632632
<ExpressionProvider user={expressionUser} app={activeApp} data={{}} features={features}>
633+
{/* Session scope for `{current_user_id}` / `{current_org_id}` filter
634+
placeholders. Mounted here rather than folded into the expression
635+
scope above: that one is the predicate evaluation context (it carries
636+
no organization), and widening it for filter resolution would couple
637+
two unrelated contracts. Renderer packages deliberately do not depend
638+
on @object-ui/auth, so the shell supplies the values (framework
639+
#3574). */}
640+
<FilterScopeProvider
641+
currentUserId={user?.id ?? null}
642+
currentOrgId={activeOrganization?.id ?? null}
643+
>
633644
<NavigationSyncEffect />
634645
<ConsoleLayout
635646
activeAppName={activeApp.name}
@@ -806,6 +817,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
806817
/>
807818
)}
808819
</ConsoleLayout>
820+
</FilterScopeProvider>
809821
</ExpressionProvider>
810822
);
811823
}

packages/app-shell/src/views/ObjectDataPage.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { Database, Lock, Plus, Save, X } from 'lucide-react';
4141
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
4242
import { usePermissions, useFieldPermissions } from '@object-ui/permissions';
4343
import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth';
44+
import { resolveFilterPlaceholders } from '@object-ui/core';
4445
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
4546
import {
4647
parseUrlFilterTriples,
@@ -79,7 +80,7 @@ export function ObjectDataPage({ dataSource, objects }: any) {
7980
const [searchParams, setSearchParams] = useSearchParams();
8081
const { can } = usePermissions();
8182
const { canRead } = useFieldPermissions(objectName ?? '');
82-
const { user } = useAuth();
83+
const { user, activeOrganization } = useAuth();
8384
const isAdmin = useIsWorkspaceAdmin();
8485
const metadataClient = useMetadataClient();
8586
// ADR-0105: group posture appends a trailing organization_id attribution
@@ -125,11 +126,14 @@ export function ObjectDataPage({ dataSource, objects }: any) {
125126
);
126127
}
127128
// Template variables mirror nav `recordId` substitution so shared links
128-
// can carry `{current_user_id}`.
129-
return readable.map(([field, op, value]) =>
130-
value === '{current_user_id}' ? [field, op, user?.id ?? value] : [field, op, value],
131-
) as FilterTriple[];
132-
}, [filterParamsKey, canRead, user?.id]);
129+
// can carry `{current_user_id}`. Routed through the shared resolver so a
130+
// link also gets `{current_org_id}` and date macros, instead of the single
131+
// hard-coded token this used to compare against (framework #3574).
132+
return resolveFilterPlaceholders(readable, {
133+
currentUserId: user?.id ?? null,
134+
currentOrgId: activeOrganization?.id ?? null,
135+
}) as FilterTriple[];
136+
}, [filterParamsKey, canRead, user?.id, activeOrganization?.id]);
133137

134138
// One display chip per field — a date-bucket drill's two range triples
135139
// (>= start, < end) collapse into a single "start → end" chip (#1752).

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react';
1313
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
14+
import { resolveFilterPlaceholders, type FilterTokenScope } from '@object-ui/core';
1415
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
1516
import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage';
1617
const ObjectChart = lazy(() =>
@@ -87,34 +88,24 @@ const VIEW_TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
8788
const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
8889

8990
/**
90-
* Replace built-in tokens (e.g. `{current_user_id}`) inside a filter array
91-
* with concrete values. Filters from platform-shipped `listViews` or saved
92-
* `sys_view` rows may declare context-sensitive predicates like
91+
* Replace built-in placeholders (e.g. `{current_user_id}`) inside a list-view
92+
* filter with concrete values. Filters from platform-shipped `listViews` or
93+
* saved `sys_view` rows may declare context-sensitive predicates like
9394
* `{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }`
9495
* — those need to be substituted before the query reaches the API.
9596
*
96-
* Recognised tokens:
97-
* • `{current_user_id}` → the authenticated user's id
97+
* Delegates to the shared `resolveFilterPlaceholders` in `@object-ui/core`,
98+
* which also expands date macros and understands `{current_org_id}`.
9899
*
99-
* Returns a deep-cloned copy with substitutions applied. Non-array input
100-
* is returned unchanged.
100+
* This used to be a local implementation that bailed on non-array input
101+
* (`if (!Array.isArray(filter)) return filter`) and knew exactly one token.
102+
* That narrowness is why it could not be reused by dashboard widgets, whose
103+
* filters are MongoDB-style objects — so widgets went without any resolution
104+
* at all and silently rendered 0 (framework #3574). Every surface now routes
105+
* through one shape-agnostic resolver.
101106
*/
102-
function substituteFilterTokens(filter: any, currentUserId: string | undefined): any {
103-
if (!Array.isArray(filter)) return filter;
104-
const sub = (v: any): any => {
105-
if (typeof v === 'string') {
106-
if (v === '{current_user_id}') return currentUserId ?? v;
107-
return v;
108-
}
109-
if (Array.isArray(v)) return v.map(sub);
110-
if (v && typeof v === 'object') {
111-
const out: any = {};
112-
for (const k of Object.keys(v)) out[k] = sub(v[k]);
113-
return out;
114-
}
115-
return v;
116-
};
117-
return filter.map(sub);
107+
function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
108+
return resolveFilterPlaceholders(filter, scope);
118109
}
119110

120111
/**
@@ -972,6 +963,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
972963
? { id: user.id, name: user.name, avatar: user.image }
973964
: FALLBACK_USER;
974965

966+
// Session scope for filter placeholders. Memoised so the resolved filter
967+
// identity is stable across renders — it feeds view schemas below.
968+
const filterScope = useMemo<FilterTokenScope>(
969+
() => ({ currentUserId: currentUser.id, currentOrgId: activeOrganization?.id ?? null }),
970+
[currentUser.id, activeOrganization?.id],
971+
);
972+
975973
// Action system for toolbar operations — refreshKey moved up (declared earlier).
976974
// Wired to confirmHandler/toastHandler so deletes use the Shadcn AlertDialog
977975
// and Sonner toast instead of native window.confirm.
@@ -1310,7 +1308,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
13101308
sort: (viewDef as any).sort ?? listSchema.sort,
13111309
filter: (() => {
13121310
const base = (viewDef as any).filter ?? listSchema.filter;
1313-
const substituted = substituteFilterTokens(base, currentUser.id);
1311+
const substituted = substituteFilterTokens(base, filterScope);
13141312
if (!urlFilters.length) return substituted;
13151313
const baseArr = Array.isArray(substituted) ? substituted : [];
13161314
return [...baseArr, ...urlFilters];
@@ -1446,7 +1444,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
14461444
...(Array.isArray(viewDef.filter) ? viewDef.filter : []),
14471445
...urlFilters,
14481446
];
1449-
const substituted = substituteFilterTokens(combined, currentUser.id);
1447+
const substituted = substituteFilterTokens(combined, filterScope);
14501448
return Array.isArray(substituted) && substituted.length ? { filters: substituted } : {};
14511449
})()),
14521450
...(viewDef.sort?.length ? { sort: viewDef.sort } : {}),

packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export * from './runtime/capabilities.js';
4242
export { composeStacks } from '@objectstack/spec';
4343
export * from './utils/drill-down.js';
4444
export * from './utils/date-macros.js';
45+
// Session-scoped filter placeholders ({current_user_id} / {current_org_id})
46+
// plus `resolveFilterPlaceholders`, the single entry point every surface
47+
// should call so no vocabulary is silently skipped (framework #3574).
48+
export * from './utils/filter-tokens.js';
4549
export * from './utils/dashboard-filters.js';
4650
export * from './utils/merge-filters.js';
4751
export * from './utils/compare-to.js';
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { resolveContextTokens, resolveFilterPlaceholders } from '../filter-tokens';
11+
12+
const SCOPE = { currentUserId: 'usr_42', currentOrgId: 'org_7', onUnresolved: null };
13+
14+
describe('resolveContextTokens', () => {
15+
// The exact failure from framework #3574: a dashboard widget's filter is a
16+
// MongoDB-style OBJECT, and the predecessor helper bailed on non-arrays —
17+
// so the token reached SQL verbatim and the widget rendered 0.
18+
it('resolves inside an object-shaped widget filter', () => {
19+
expect(
20+
resolveContextTokens({ owner: '{current_user_id}', status: 'open' }, SCOPE),
21+
).toEqual({ owner: 'usr_42', status: 'open' });
22+
});
23+
24+
it('resolves inside an array-shaped list-view filter', () => {
25+
expect(
26+
resolveContextTokens(
27+
[{ field: 'owner', operator: 'equals', value: '{current_user_id}' }],
28+
SCOPE,
29+
),
30+
).toEqual([{ field: 'owner', operator: 'equals', value: 'usr_42' }]);
31+
});
32+
33+
it('resolves inside triple-shaped filters', () => {
34+
expect(resolveContextTokens([['owner', '=', '{current_user_id}']], SCOPE)).toEqual([
35+
['owner', '=', 'usr_42'],
36+
]);
37+
});
38+
39+
it('reaches nested $and / $or branches and operator objects', () => {
40+
expect(
41+
resolveContextTokens(
42+
{
43+
$and: [
44+
{ status: 'open' },
45+
{ $or: [{ owner: '{current_user_id}' }, { org: '{current_org_id}' }] },
46+
],
47+
reviewer: { $in: ['{current_user_id}', 'usr_9'] },
48+
},
49+
SCOPE,
50+
),
51+
).toEqual({
52+
$and: [{ status: 'open' }, { $or: [{ owner: 'usr_42' }, { org: 'org_7' }] }],
53+
reviewer: { $in: ['usr_42', 'usr_9'] },
54+
});
55+
});
56+
57+
it('accepts the ${token} spelling', () => {
58+
expect(resolveContextTokens({ owner: '${current_user_id}' }, SCOPE)).toEqual({
59+
owner: 'usr_42',
60+
});
61+
});
62+
63+
it('leaves ordinary values, partial braces and non-strings alone', () => {
64+
const input = {
65+
name: 'Acme {Corp}',
66+
note: 'owner is {current_user_id} today',
67+
count: 3,
68+
active: true,
69+
missing: null,
70+
};
71+
expect(resolveContextTokens(input, SCOPE)).toEqual(input);
72+
});
73+
74+
it('passes date macros through untouched — they are a separate vocabulary', () => {
75+
expect(resolveContextTokens({ created_at: { $gte: '{today}' } }, SCOPE)).toEqual({
76+
created_at: { $gte: '{today}' },
77+
});
78+
});
79+
80+
it('passes nav-only context-selector ids through silently', () => {
81+
const warn = vi.fn();
82+
expect(
83+
resolveContextTokens({ pkg: '{active_package}' }, { ...SCOPE, onUnresolved: warn }),
84+
).toEqual({ pkg: '{active_package}' });
85+
expect(warn).not.toHaveBeenCalled();
86+
});
87+
88+
it('does not mutate its input', () => {
89+
const input = { owner: '{current_user_id}' };
90+
resolveContextTokens(input, SCOPE);
91+
expect(input).toEqual({ owner: '{current_user_id}' });
92+
});
93+
94+
// Leaving the token yields an empty result set. Dropping the clause would
95+
// WIDEN the result set, which is far worse than silently narrowing.
96+
it('leaves a known token intact when scope has no value, and warns', () => {
97+
const warn = vi.fn();
98+
expect(
99+
resolveContextTokens({ owner: '{current_user_id}' }, { onUnresolved: warn }),
100+
).toEqual({ owner: '{current_user_id}' });
101+
expect(warn).toHaveBeenCalledTimes(1);
102+
expect(warn.mock.calls[0][0]).toContain('current_user_id');
103+
});
104+
105+
it('warns with a suggestion on near-miss spellings', () => {
106+
const warn = vi.fn();
107+
resolveContextTokens(
108+
{ a: '{current_user}', b: '{user_id}', c: '{org_id}' },
109+
{ ...SCOPE, onUnresolved: warn },
110+
);
111+
expect(warn).toHaveBeenCalledTimes(3);
112+
expect(warn.mock.calls[0][0]).toContain('{current_user_id}');
113+
expect(warn.mock.calls[2][0]).toContain('{current_org_id}');
114+
});
115+
116+
it('handles null / undefined filters', () => {
117+
expect(resolveContextTokens(null, SCOPE)).toBeNull();
118+
expect(resolveContextTokens(undefined, SCOPE)).toBeUndefined();
119+
});
120+
});
121+
122+
describe('resolveFilterPlaceholders', () => {
123+
// Calling only one resolver is the defect behind #3574 — dashboard widgets
124+
// expanded date macros and silently skipped session tokens.
125+
it('expands BOTH vocabularies in a single call', () => {
126+
const out = resolveFilterPlaceholders(
127+
{ owner: '{current_user_id}', created_at: { $gte: '{today}' } },
128+
SCOPE,
129+
new Date('2026-07-27T12:00:00Z'),
130+
);
131+
expect(out.owner).toBe('usr_42');
132+
expect(out.created_at.$gte).toMatch(/^\d{4}-\d{2}-\d{2}$/);
133+
expect(out.created_at.$gte).not.toBe('{today}');
134+
});
135+
136+
it('is a no-op on filters with no placeholders', () => {
137+
expect(resolveFilterPlaceholders({ status: 'open' }, SCOPE)).toEqual({ status: 'open' });
138+
});
139+
140+
it('handles null / undefined filters', () => {
141+
expect(resolveFilterPlaceholders(undefined, SCOPE)).toBeUndefined();
142+
});
143+
});

0 commit comments

Comments
 (0)