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
53 changes: 53 additions & 0 deletions .changeset/analytics-widget-query-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/spec": patch
"@objectstack/service-analytics": patch
"@objectstack/rest": patch
---

feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588)

Three presentation options were accepted by the metadata layer and then dropped
by the analytics query builder. They reached no SQL, produced no error, and the
only way to notice was to read the `sql` a dataset response echoes — so a
dashboard could declare `dateGranularity: 'month'` and quietly render one bar
per record.

- **`dateGranularity` now buckets.** `DatasetSelection` gained an optional
`dateGranularity`, applied to every selected `date` dimension. Precedence per
dimension: an explicit `timeDimensions` granularity, then the selection's,
then the dataset dimension's own default. A widget can bucket a trend by month
without the dataset committing every other consumer to that granularity.
- **`order` / `limit` / `offset` now apply on every path.** They are applied to
the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo`
columns attach, and after derived measures are computed — so a derived measure
is a valid sort key and the ObjectQL aggregate path (which has no ordering
grammar, and which native SQL hands every date-bucketed query to) orders
identically to native SQL. A single-query selection still pushes the window
down into the statement. An `order` key that names nothing the selection
projects is now rejected (400) rather than silently ignored.
- **`limit` is deterministic.** Without an `order`, a limit orders by the
selected dimensions first, so it truncates a reproducible window instead of an
arbitrary subset.
- **Widget `options` is a contract again.** The four query-affecting keys
(`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are
declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is
an author-time error. The bag stays open — renderer extras (`icon`, `columns`,
`striped`, …) pass through untouched.

Two latent bugs surfaced while fixing the above and are fixed here too:

- `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped
supplementary query selects one measure, so an inherited `ORDER BY` named a
column it never selected, and an inherited `LIMIT` truncated it before the
merge — dropping rows from the assembled grid. Nothing hit this only because
nothing passed `order`.
- The `compareTo` pass built its query by hand and skipped granularity
resolution, so a month-bucketed primary grid was merged against raw-timestamp
comparison rows. No dimension key matched and every `<measure>__compare`
column came back empty.

`ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`,
`WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined).
Previously the `sql` field simply vanished from the response whenever a query
was date-bucketed, leaving an author unable to tell "not implemented" from "this
strategy doesn't report".
23 changes: 20 additions & 3 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Color variant for dashboard widgets (e.g., KPI cards).
## TypeScript Usage

```typescript
import { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
import { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, DashboardWidgetOptions, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';
import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, DashboardWidgetOptions, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui';

// Validate data
const result = Dashboard.parse(data);
Expand Down Expand Up @@ -107,13 +107,30 @@ Dashboard header action
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
| **values** | `string[]` | ✅ | Measure names — Y (at least one) |
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
| **options** | `any` | optional | Widget specific configuration |
| **options** | `Record<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| 'false'>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
| **responsive** | `{ breakpoint?: Enum<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'>; hiddenOn?: Enum<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'>[]; columns?: object; order?: object }` | optional | Responsive layout configuration |
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


---

## DashboardWidgetOptions

Widget configuration — declared query keys + open renderer extras

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Bucket selected date dimensions (day/week/month/quarter/year) |
| **sortBy** | `string` | optional | Dimension/measure name to order by |
| **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy |
| **limit** | `integer` | optional | Max rows (applied after ordering) |
| **stageOrder** | `string \| number \| boolean[]` | optional | Explicit category order for funnel/pyramid stages (stored values) |


---

## GlobalFilter
Expand Down
43 changes: 42 additions & 1 deletion content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,50 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot):
| `chartConfig` | `object` | optional | Advanced chart configuration |
| `colorVariant` | `enum` | optional | KPI/card accent color |
| `compareTo` | `enum \| object` | optional | Period-over-period comparison window |
| `options` | `object` | optional | Additional display options |
| `options` | `object` | optional | Renderer extras **plus** the query keys below |
| `responsive` | `object` | optional | Responsive behavior |

### Widget `options`

`options` is an open bag: keys the renderer understands (`icon`, `columns`,
`striped`, `density`, …) pass through untouched. Five keys are **declared**,
because they are not presentation-only — four of them change the query the
dataset compiles to:

| Key | Type | Effect |
| :--- | :--- | :--- |
| `dateGranularity` | `day \| week \| month \| quarter \| year` | Buckets this widget's selected **date** dimensions. Overrides the dataset dimension's own default for this widget only. |
| `sortBy` | `string` | Orders rows by a dimension or measure **this widget selects**. |
| `sortOrder` | `asc \| desc` | Direction for `sortBy` (default `asc`). |
| `limit` | `number` | Max rows, applied **after** ordering. |
| `stageOrder` | `(string \| number \| boolean)[]` | Explicit stage order for `funnel` / `pyramid`, as the dimension's **stored values**. Omit to use the dimension field's picklist option order. |

Because they are declared, a misspelling (`sortDirection`, `granularity`) is an
author-time error rather than an option that reads as if it works.

```ts
{
id: 'revenue_trend',
type: 'area',
dataset: 'opportunity_metrics',
dimensions: ['close_date'],
values: ['total_amount'],
// Group by month rather than by raw timestamp.
options: { dateGranularity: 'month' },
}
```

Notes on behaviour:

- `sortBy` must name something the widget selects; the server rejects an order
key it cannot resolve rather than ignoring it.
- A `limit` with no `sortBy` orders by the selected dimensions first, so it
truncates a reproducible window instead of an arbitrary subset.
- Ordering is applied to the finished grid, so a **derived measure** is a valid
`sortBy` even though no single SQL statement computes it.
- A `funnel` with no declared stage order falls back to sorting by value
descending.

## Datasets

Every widget binds to a **dataset** — the single semantic layer (ADR-0021).
Expand Down
2 changes: 1 addition & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5260,7 +5260,7 @@ export class RestServer {
const msg = String(error?.message ?? error ?? '');
// Dataset-compiler D-C / unsupported-aggregate / read-scope
// errors are client-side mistakes — surface as 400.
if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql/.test(msg)) {
if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql|not a selected dimension or measure|is not a subset of the selected dimensions/.test(msg)) {
return res.status(400).json({ code: 'DATASET_INVALID', message: msg.slice(0, 1000) });
}
logError('[REST] Analytics dataset query error:', error);
Expand Down
Loading
Loading