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
30 changes: 30 additions & 0 deletions .changeset/report-drill-down-range-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@object-ui/core": minor
"@object-ui/app-shell": minor
"@object-ui/plugin-report": minor
"@object-ui/plugin-dashboard": minor
---

feat(report): drill a date-bucket cell into its time range, not a superset (#1752)

Clicking a report/dashboard cell grouped by a `dateGranularity` date dimension
("2026-Q2") used to drill into a **superset** — the date dimension was skipped,
so the record list spanned every time bucket. It now scopes to the clicked
bucket's half-open range, consuming the framework's new `drillRanges` sidecar.

- **`@object-ui/core`** — `buildDatasetDrillFilter` accepts the per-row
`drillRanges` and emits an ObjectQL range operator object
(`{ [field]: { $gte, $lt } }`) alongside the equality dims.
- **`@object-ui/plugin-report` / `@object-ui/plugin-dashboard`** — the report
renderer and dashboard widget forward `drillRanges`, and a **date-only**
report (no equality drill dim) is now drillable via the range alone.
- **`@object-ui/app-shell`** — the "Open in list →" escape hatch
(`useOpenRecordList`) now targets the ADR-0055 **bare data surface**
(`/:object/data`, "the URL is the view" — no baked-in view filter to
over-narrow the drill) and serializes a range to the
`filter[field][gte|lt]` operator contract. `ObjectDataPage` parses those
operators (equality shorthand unchanged), renders a range as a single chip,
and removes both bounds together. A new `drillUrlFilters` module owns the
write/read serialization so both sides can't drift (round-trip tested).

Companion to the framework analytics change (objectstack-ai/framework#3256).
34 changes: 14 additions & 20 deletions packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { usePermissions, useFieldPermissions } from '@object-ui/permissions';
import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth';
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
import {
parseUrlFilterTriples,
groupFilterChips,
deleteFieldFilterParams,
type FilterTriple,
} from './drillUrlFilters';
import {
defaultColumnsFromObject,
defaultKanbanFromObject,
Expand All @@ -55,19 +61,6 @@ import { useMetadataClient } from './metadata-admin/useMetadata';
import { createRuntimeMetadata } from './runtime-metadata-persistence';
import { CreateViewDialog } from './CreateViewDialog';

/** Filter triple shape shared with view metadata: [field, operator, value]. */
type FilterTriple = [string, string, unknown];

/** Parse `filter[<field>]=<value>` search params into equality triples. */
function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTriple[] {
const out: FilterTriple[] = [];
searchParams.forEach((value, key) => {
const m = /^filter\[(.+)\]$/.exec(key);
if (m && m[1] && value !== '') out.push([m[1], '=', value]);
});
return out;
}

/** Field types the auto-derived user-filter bar offers as dropdowns. */
const USER_FILTER_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']);
const MAX_USER_FILTERS = 4;
Expand Down Expand Up @@ -126,13 +119,14 @@ export function ObjectDataPage({ dataSource, objects }: any) {
) as FilterTriple[];
}, [filterParamsKey, canRead, user?.id]);

// One display chip per field — a date-bucket drill's two range triples
// (>= start, < end) collapse into a single "start → end" chip (#1752).
const filterChips = React.useMemo(() => groupFilterChips(urlFilters), [urlFilters]);

const removeUrlFilter = React.useCallback(
(field: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete(`filter[${field}]`);
return next;
});
// Clears the equality param AND both range-bound operator params for the field.
setSearchParams((prev) => deleteFieldFilterParams(new URLSearchParams(prev), field));
},
[setSearchParams],
);
Expand Down Expand Up @@ -345,13 +339,13 @@ export function ObjectDataPage({ dataSource, objects }: any) {
<span className="text-xs text-muted-foreground">
{t('console.objectData.filteredBy', { defaultValue: 'Filtered by' })}
</span>
{urlFilters.map(([field, , value]) => (
{filterChips.map(({ field, text }) => (
<span
key={field}
className="inline-flex items-center gap-1 rounded-full border bg-muted/40 px-2 py-0.5 text-xs"
>
<span className="font-medium">{fieldLabel(objectDef.name, field, field)}</span>
<span className="text-muted-foreground">= {String(value)}</span>
<span className="text-muted-foreground">{text}</span>
<button
type="button"
onClick={() => removeUrlFilter(field)}
Expand Down
106 changes: 106 additions & 0 deletions packages/app-shell/src/views/drillUrlFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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 } from 'vitest';
import {
parseUrlFilterTriples,
serializeDrillFilterParams,
deleteFieldFilterParams,
groupFilterChips,
type FilterTriple,
} from './drillUrlFilters';

const parse = (qs: string) => parseUrlFilterTriples(new URLSearchParams(qs));

describe('parseUrlFilterTriples', () => {
it('parses the equality shorthand', () => {
expect(parse('filter[status]=open')).toEqual([['status', '=', 'open']]);
});

it('parses range/comparison operators into ObjectQL ops', () => {
expect(parse('filter[close_date][gte]=2026-04-01&filter[close_date][lt]=2026-07-01')).toEqual([
['close_date', '>=', '2026-04-01'],
['close_date', '<', '2026-07-01'],
]);
});

it('keeps relationship-path field names intact', () => {
expect(parse('filter[account.region]=NA')).toEqual([['account.region', '=', 'NA']]);
});

it('ignores an unknown operator (never downgrades it to equality)', () => {
expect(parse('filter[x][bogus]=1')).toEqual([]);
});

it('skips empty values', () => {
expect(parse('filter[status]=')).toEqual([]);
});
});

describe('serializeDrillFilterParams', () => {
it('serializes an equality value', () => {
expect(serializeDrillFilterParams({ status: 'open' }).toString()).toBe('filter%5Bstatus%5D=open');
});

it('serializes an ObjectQL range operator object to gte/lt params', () => {
const qs = serializeDrillFilterParams({ close_date: { $gte: '2026-04-01', $lt: '2026-07-01' } });
expect(qs.get('filter[close_date][gte]')).toBe('2026-04-01');
expect(qs.get('filter[close_date][lt]')).toBe('2026-07-01');
});

it('skips null/undefined and never stringifies an unknown object to "[object Object]"', () => {
const qs = serializeDrillFilterParams({ a: null, b: undefined, weird: { nope: 1 } });
expect(qs.toString()).toBe('');
});
});

describe('round-trip: serialize → parse (write and read sides agree)', () => {
it('a mixed equality + date-range drill filter survives the URL round-trip', () => {
const filter = { stage: 'qualification', close_date: { $gte: '2026-06-01', $lt: '2026-07-01' } };
const triples = parseUrlFilterTriples(serializeDrillFilterParams(filter));
expect(triples).toEqual<FilterTriple[]>([
['stage', '=', 'qualification'],
['close_date', '>=', '2026-06-01'],
['close_date', '<', '2026-07-01'],
]);
});
});

describe('deleteFieldFilterParams', () => {
it('removes the equality AND both range-bound params for a field, leaving others', () => {
const params = new URLSearchParams(
'filter[close_date][gte]=2026-06-01&filter[close_date][lt]=2026-07-01&filter[stage]=qualification',
);
deleteFieldFilterParams(params, 'close_date');
expect(params.toString()).toBe('filter%5Bstage%5D=qualification');
});
});

describe('groupFilterChips', () => {
it('collapses a date range into a single from → to chip', () => {
expect(
groupFilterChips([
['close_date', '>=', '2026-04-01'],
['close_date', '<', '2026-07-01'],
]),
).toEqual([{ field: 'close_date', text: '2026-04-01 → 2026-07-01' }]);
});

it('renders an equality chip and preserves field order', () => {
expect(
groupFilterChips([
['stage', '=', 'qualification'],
['close_date', '>=', '2026-04-01'],
['close_date', '<', '2026-07-01'],
]),
).toEqual([
{ field: 'stage', text: '= qualification' },
{ field: 'close_date', text: '2026-04-01 → 2026-07-01' },
]);
});
});
118 changes: 118 additions & 0 deletions packages/app-shell/src/views/drillUrlFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* 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.
*/

/**
* URL filter (de)serialization shared by the drill "escape hatch"
* (`useOpenRecordList`, the WRITE side) and the ADR-0055 bare data surface
* (`ObjectDataPage`, the READ side). Keeping both sides in ONE module keeps the
* `filter[<field>][<op>]` operator contract (#1752) from drifting between the
* code that emits a URL and the code that parses it back.
*
* Contract:
* - equality `filter[field]=value` → `[field, '=', value]`
* - range / cmp `filter[field][gte|lte|gt|lt]=v` → `[field, '>=' | … , v]`
* A date-bucket drill emits `gte` + `lt` to scope a list to a time bucket.
*/

/** Filter triple shape shared with view metadata: [field, operator, value]. */
export type FilterTriple = [string, string, unknown];

/** URL range/comparison operator suffix → ObjectQL operator (READ side). */
export const URL_FILTER_OPS: Record<string, string> = { gte: '>=', lte: '<=', gt: '>', lt: '<' };

/** ObjectQL range operator key → URL param suffix (WRITE side). Inverse of the
* relevant `URL_FILTER_OPS` entries. */
export const RANGE_OP_PARAM: Record<string, string> = { $gte: 'gte', $lte: 'lte', $gt: 'gt', $lt: 'lt' };

/**
* Parse `filter[<field>]=<value>` (equality) and `filter[<field>][<op>]=<value>`
* (range/comparison) search params into ObjectQL triples. An unknown operator
* suffix is ignored (never silently downgraded to equality).
*/
export function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTriple[] {
const out: FilterTriple[] = [];
searchParams.forEach((value, key) => {
if (value === '') return;
// Operator form FIRST — the field capture must not swallow the `[op]` suffix.
const mOp = /^filter\[([^\]]+)\]\[([a-z]+)\]$/.exec(key);
if (mOp) {
const op = URL_FILTER_OPS[mOp[2]];
if (op) out.push([mOp[1], op, value]);
return;
}
const m = /^filter\[([^\]]+)\]$/.exec(key);
if (m && m[1]) out.push([m[1], '=', value]);
});
return out;
}

/**
* Serialize a drill filter object into `filter[...]` search params. An ObjectQL
* range operator object (`{ $gte, $lt }`) becomes `filter[field][gte|lt]`; a
* plain value becomes `filter[field]`. `null`/`undefined` values and objects
* with no recognized operator are skipped (drill degrades to a superset) rather
* than stringified to `"[object Object]"`.
*/
export function serializeDrillFilterParams(
filter: Record<string, unknown> | undefined,
): URLSearchParams {
const params = new URLSearchParams();
if (!filter) return params;
for (const [field, value] of Object.entries(filter)) {
if (value == null) continue;
if (typeof value === 'object' && !Array.isArray(value)) {
for (const [op, suffix] of Object.entries(RANGE_OP_PARAM)) {
const bound = (value as Record<string, unknown>)[op];
if (bound != null) params.set(`filter[${field}][${suffix}]`, String(bound));
}
continue; // handled (range ops) or skipped — never String(object)
}
params.set(`filter[${field}]`, String(value));
}
return params;
}

/**
* Delete the equality param AND every operator param (both range bounds) for a
* field, so removing a date-range chip drops the whole range together (#1752).
* Mutates and returns `params`.
*/
export function deleteFieldFilterParams(params: URLSearchParams, field: string): URLSearchParams {
const prefix = `filter[${field}]`;
for (const key of Array.from(params.keys())) {
if (key === prefix || key.startsWith(`${prefix}[`)) params.delete(key);
}
return params;
}

/**
* Group filter triples into ONE display chip per field, preserving first-seen
* order. A date-bucket drill contributes two triples for the same field
* (`>= start`, `< end`); they collapse into a single `start → end` range chip.
*/
export function groupFilterChips(triples: FilterTriple[]): Array<{ field: string; text: string }> {
const order: string[] = [];
const byField = new Map<string, FilterTriple[]>();
for (const tr of triples) {
if (!byField.has(tr[0])) {
byField.set(tr[0], []);
order.push(tr[0]);
}
byField.get(tr[0])!.push(tr);
}
return order.map((field) => {
const list = byField.get(field)!;
const gte = list.find(([, op]) => op === '>=' || op === '>');
const lt = list.find(([, op]) => op === '<' || op === '<=');
const text =
gte || lt
? `${gte ? String(gte[2]) : '…'} → ${lt ? String(lt[2]) : '…'}`
: `= ${String(list[0][2])}`;
return { field, text };
});
}
28 changes: 15 additions & 13 deletions packages/app-shell/src/views/useOpenRecordList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@

import { useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { serializeDrillFilterParams } from './drillUrlFilters';

/**
* `useOpenRecordList` — the console's implementation of the drill "escape
* hatch" (`DrillNavigationContext.openRecordList`).
*
* Navigates to an object's full list page, scoped by a record filter, using the
* console's `/apps/:appName/:object?filter[field]=value` route shape (the same
* format `ReportView`'s drill navigation uses). Wire it into a
* `DrillNavigationProvider` so the dashboard/report drill drawers can offer
* Navigates to the object's ADR-0055 bare data surface, scoped by a record
* filter, using the console's `/apps/:appName/:object/data?filter[...]` route
* shape. Equality dims serialize to `filter[field]=value`; a date-bucket drill's
* range serializes to `filter[field][gte]=…&filter[field][lt]=…` (#1752). Wire it
* into a `DrillNavigationProvider` so the dashboard/report drill drawers can offer
* "Open in list →" and honor `drillDown.target: 'navigate'`.
*/
export function useOpenRecordList(): (objectName: string, filter?: Record<string, unknown>) => void {
Expand All @@ -25,16 +27,16 @@ export function useOpenRecordList(): (objectName: string, filter?: Record<string

return useCallback(
(objectName: string, filter?: Record<string, unknown>) => {
const params = new URLSearchParams();
if (filter) {
for (const [field, value] of Object.entries(filter)) {
if (value == null) continue;
params.set(`filter[${field}]`, String(value));
}
}
const qs = params.toString();
// A date-bucket drill carries an ObjectQL range operator object
// (`{ $gte, $lt }`); the shared serializer emits it as `filter[field][gte|lt]`
// (never "[object Object]"). Equality dims stay `filter[field]=value` (#1752).
const qs = serializeDrillFilterParams(filter).toString();
const base = appName ? `/apps/${appName}` : '';
navigate(`${base}/${objectName}${qs ? `?${qs}` : ''}`);
// ADR-0055 bare data surface (`/:object/data`): "the URL is the view" — no
// saved-view filter is baked in, so the drill scope is exactly these
// conditions. (The object route stacks URL filters ON TOP of the default
// view's own filter, which can silently over-narrow a drill.)
navigate(`${base}/${objectName}/data${qs ? `?${qs}` : ''}`);
},
[navigate, appName],
);
Expand Down
Loading
Loading