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
56 changes: 56 additions & 0 deletions .changeset/dashboard-filter-context-tokens.md
Original file line number Diff line number Diff line change
@@ -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`.
16 changes: 14 additions & 2 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,7 +114,7 @@ function DraftReviewNavigator({ appName }: { appName: string | undefined }) {

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

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

return (
<ExpressionProvider user={expressionUser} app={activeApp} data={{}} features={features}>
{/* 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). */}
<FilterScopeProvider
currentUserId={user?.id ?? null}
currentOrgId={activeOrganization?.id ?? null}
>
<NavigationSyncEffect />
<ConsoleLayout
activeAppName={activeApp.name}
Expand Down Expand Up @@ -806,6 +817,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
/>
)}
</ConsoleLayout>
</FilterScopeProvider>
</ExpressionProvider>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
48 changes: 23 additions & 25 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down Expand Up @@ -86,34 +87,24 @@ const VIEW_TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
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);
}

/**
Expand Down Expand Up @@ -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<FilterTokenScope>(
() => ({ 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.
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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 } : {}),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
143 changes: 143 additions & 0 deletions packages/core/src/utils/__tests__/filter-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading