diff --git a/.changeset/report-drill-down-range-filter.md b/.changeset/report-drill-down-range-filter.md new file mode 100644 index 000000000..38948549d --- /dev/null +++ b/.changeset/report-drill-down-range-filter.md @@ -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). diff --git a/packages/app-shell/src/views/ObjectDataPage.tsx b/packages/app-shell/src/views/ObjectDataPage.tsx index 7b263faf4..3c27e316f 100644 --- a/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.tsx @@ -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, @@ -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[]=` 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; @@ -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], ); @@ -345,13 +339,13 @@ export function ObjectDataPage({ dataSource, objects }: any) { {t('console.objectData.filteredBy', { defaultValue: 'Filtered by' })} - {urlFilters.map(([field, , value]) => ( + {filterChips.map(({ field, text }) => ( {fieldLabel(objectDef.name, field, field)} - = {String(value)} + {text}