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
51 changes: 51 additions & 0 deletions .changeset/11526-object-metric-rule-filter-spec-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@object-ui/plugin-dashboard': patch
---

fix(plugin-dashboard): an `object-metric` with a structured `groupBy` and a rule-list `filter` draws its number

An authored node such as

```ts
{
type: 'object-metric',
properties: {
objectName: 'opportunity',
aggregate: { field: 'amount', function: 'sum', groupBy: { field: 'close_date', dateGranularity: 'month' } },
filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
},
}
```

passes both authoring faces (`safeValidateSchema`, which `objectui validate` runs, and
`StrictAnyComponentSchema`), and on `ObjectStackAdapter` it drew the adapter's refusal
("the spec-shape branch received a `where` array that is not a filter") instead of its number
(objectui#11526).

A structured `groupBy` sends the tile down the spec-shape aggregate query (objectui#8613), and
`ObjectMetricWidget` passed the bag's `filter`, a `ViewFilterRule[]`, into that query's `where`
as written. The adapter posts that `where` verbatim and refuses a rule list there with
`UnloweredAggregateWhereError` (objectui#6825), so the query was never sent. The same rule list
with a string `groupBy` drew its number, because the legacy aggregate bag carries it as
`filter` and the adapter lowers that key itself.

The tile now lowers the rule list before it builds the spec-shape query: `toFilterNode` from
`@object-ui/core` turns it into filter AST, and `parseFilterAST` from `@objectstack/spec/data`
turns that into the `FilterCondition` that `QuerySchema.where` declares. These are the same two
stages the adapter applies to the rule list on the legacy bag, where its own translator is
private to `@object-ui/data-objectstack`. An array that is neither a rule list nor filter AST is
passed on unparsed, so the adapter still refuses it rather than receiving no filter and
aggregating every row.

What does not move:

- a string or absent `groupBy`: the legacy bag still carries the rule list as `filter`, and the
adapter lowers it as before;
- a flat record filter (`{ stage: 'won' }`): it is already a `FilterCondition` and still travels
verbatim as `where`;
- the drill-down drawer: it still receives the tile's resolved filter, unchanged;
- `UnloweredAggregateWhereError`: the adapter still refuses an unlowered rule list from any
caller that skips the lowering.

One posted body changes without changing its answer: an empty rule list on the spec-shape query
now sends no `where`. It used to send `where: []`, which the platform engine reads as no filter.
59 changes: 56 additions & 3 deletions packages/plugin-dashboard/src/ObjectMetricWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react';
import { SchemaRendererContext, useFilterScope, useDataInvalidation } from '@object-ui/react';
import { isDrillEnabled, resolveDrillTitle, isStructuredGroupBy, objectAggregateSpecQuery } from '@object-ui/core';
import { isDrillEnabled, resolveDrillTitle, isStructuredGroupBy, objectAggregateSpecQuery, toFilterNode } from '@object-ui/core';
import type { I18nLabel, ObjectChartSchema, ObjectMetricDrillDownConfig } from '@object-ui/types';
import {
useLocalization,
Expand All @@ -18,7 +18,7 @@ import {
pickLocalized,
} from '@object-ui/i18n';
import { formatCurrency, formatPercent, percentCellScale } from '@object-ui/fields';
import { resolveFieldScale } from '@objectstack/spec/data';
import { isFilterAST, parseFilterAST, resolveFieldScale } from '@objectstack/spec/data';
import { MetricWidget, metricPatternDecimals } from './MetricWidget';
import { DrillDownDrawer } from './DrillDownDrawer';
import {
Expand Down Expand Up @@ -105,6 +105,54 @@ function ownFractionDigits(value: number): number {
return Math.min(Math.max(fraction - (exponent ? Number(exponent) : 0), 0), 20);
}

/**
* The spec-shape query's `where`, lowered from the tile's filter
* (objectui#11526).
*
* The authored bag's `filter` is a `ViewFilterRule[]`: `ObjectMetricPropsSchema`
* in `@objectstack/spec/ui` declares it so. The two wires take it differently:
*
* - the legacy bag carries it raw as `filter`, and `ObjectStackAdapter`
* lowers it there (`lowerAnalyticsFilterForWire`: the rule list becomes
* filter AST through `translateFilterArray`, then a `FilterCondition`
* through `parseFilterAST`);
* - the spec-shape query posts `where` VERBATIM, and the adapter refuses a
* rule list there with `UnloweredAggregateWhereError` (objectui#6825,
* ruling A: refuse, never lower). The producer lowers it.
*
* The adapter's lowering is module-private to `@object-ui/data-objectstack`,
* which this plugin does not depend on (it reads any `DataSource`), and
* `@object-ui/core` cannot re-export it: the adapter package depends on core.
* So the same two stages are taken from where they are public. `toFilterNode`
* is core's sink for a `ViewFilterRule[]` (the one `object-grid` lowers its own
* rule-list `filter` through, via `toFilterNodeSafely`), and `parseFilterAST`
* is the spec's single sink, the call the adapter makes. The result is the
* `FilterCondition` that `QuerySchema.where` declares; the pin
* `ObjectMetricWidget.ruleFilterSpecShape-11526` checks that every operator the
* spec's rule vocabulary declares lowers to one. That it equals the legacy
* wire's `where` was measured once, on objectui#11526's pull request, against
* the real adapter; nothing in this repo re-derives that equality.
*
* Three inputs keep what they had:
*
* - a NON-array filter is already a `FilterCondition` (the flat record form),
* so it is returned untouched;
* - an EMPTY array lowers to `undefined`, so no `where` is posted, as the
* legacy wire posts none;
* - an array the lowering could not turn into a filter (`isFilterAST` says
* no) is returned unparsed. `parseFilterAST` answers `undefined` for such
* an array, which would post an UNFILTERED aggregate under a filtered
* question; handed on, it meets the adapter's refusal and nothing is sent.
*
* A rule either sink refuses throws here, and `fetchMetric` shows it as the
* tile's error.
*/
function specShapeWhere(filter: unknown): unknown {
if (!Array.isArray(filter)) return filter;
const node = toFilterNode(filter);
return node !== undefined && isFilterAST(node) ? parseFilterAST(node) : node;
}

/**
* ObjectMetricWidget — Data-bound metric widget.
*
Expand Down Expand Up @@ -419,8 +467,13 @@ export const ObjectMetricWidget: React.FC<ObjectMetricWidgetProps> = ({
// measure is projected under `chartMeasureKey`'s alias — the raw `field`,
// or the literal `'count'` for a fieldless count — and both are limbs the
// two chains already try (`row[field]`, `r.count`).
//
// The filter is lowered for the spec-shape query only (`specShapeWhere`,
// objectui#11526): its `where` is posted verbatim, while the legacy bag's
// `filter` is lowered by the adapter. The drill drawer below still gets
// `resolvedFilter` as it was.
const results = isStructuredGroupBy(groupBy)
? await ds.aggregate(objectName, objectAggregateSpecQuery(aggregate, groupBy, filterForRun))
? await ds.aggregate(objectName, objectAggregateSpecQuery(aggregate, groupBy, specShapeWhere(filterForRun)))
: await ds.aggregate(objectName, {
field: aggregate.field,
function: aggregate.function,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11526 — an authored `object-metric` whose `aggregate.groupBy` is
* the structured node and whose `filter` is the bag's rule list draws its
* number.
*
* The bag's `filter` is a `ViewFilterRule[]` (`ObjectMetricPropsSchema` in
* `@objectstack/spec/ui`). A structured `groupBy` sends the tile down the
* spec-shape query (objectui#8613), whose `where` `ObjectStackAdapter` posts
* verbatim and refuses as a rule list with `UnloweredAggregateWhereError`
* (objectui#6825, ruling A). So before this card the tile drew that refusal,
* while the same rule list on the legacy bag (a string `groupBy`) drew the
* number, because the adapter lowers the legacy bag's `filter` itself.
*
* The fix lowers the rule list in the producer, for the spec-shape query only
* (`specShapeWhere` in `ObjectMetricWidget`). The oracle is the POSTED bag:
* `where` must be a `FilterCondition`, the dialect `QuerySchema.where`
* declares and the adapter's gate passes untouched. That half of the seam is
* pinned in `data-objectstack`'s `aggregate-spec-shape-where.test.ts` ("a
* FilterCondition OBJECT is not an array, and this gate leaves it alone"), and
* the refusal this card keeps is pinned there too ("throws
* UnloweredAggregateWhereError instead of posting a rule array").
*
* The controls are load-bearing: the legacy bag still carries the rule list
* RAW (the adapter lowers that wire), and a flat record filter still travels
* verbatim on the spec-shape query.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { ObjectMetricBlockSchema, StrictAnyComponentSchema, safeValidateSchema } from '@object-ui/types/zod';
import { FilterConditionSchema } from '@objectstack/spec/data';
import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui';
// Registers `object-metric` at MODULE scope, never inside a hook —
// object-ui/no-dynamic-import-in-test-hook.
import '../index';

afterEach(cleanup);

type Params = Record<string, unknown>;

/** An adapter that can aggregate, recording every call's options bag. */
const aggregatingAdapter = (rows: Record<string, unknown>[]) => ({
aggregate: vi.fn(async (_object: string, _params: Params) => rows),
find: vi.fn(async (_object: string, _params: Params) => ({ data: [] as unknown[] })),
});

/**
* The authored node, in the `properties` form. The card's document, the
* showcase-shaped one and each per-operator document are checked on the
* block's own arm (`ObjectMetricBlockSchema`) and on both authoring faces:
* `safeValidateSchema` (the tolerant `AnyComponentSchema`, which
* `objectui validate` runs) and `StrictAnyComponentSchema`.
*/
const node = (properties: Record<string, unknown>) => ({ type: 'object-metric', properties });

const mount = (schema: Record<string, unknown>, adapter: unknown) =>
render(
<SchemaRendererProvider dataSource={adapter as never}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererProvider>,
);

/** The options bag of the first `aggregate()` call. */
const bagOf = (adapter: ReturnType<typeof aggregatingAdapter>): Params =>
adapter.aggregate.mock.calls[0][1];

/** The date-bucketing node, in the spelling `ChartGroupBySchema` admits. */
const MONTHLY = { field: 'close_date', dateGranularity: 'month' };

/** The card's rule list. */
const WON = [{ field: 'stage', operator: 'equals', value: 'won' }];

describe('object-metric — a structured `groupBy` with a rule-list `filter` draws its number (objectui#11526)', () => {
it('posts the rule list lowered to a FilterCondition as `where`, and draws the number', async () => {
// The card's document, as authored. Both authoring faces accept it, which
// is what makes the refusal a defect rather than an authoring error.
const doc = node({
label: 'Won',
objectName: 'opportunity',
aggregate: { field: 'amount', function: 'sum', groupBy: MONTHLY },
filter: WON,
});
expect(ObjectMetricBlockSchema.safeParse(doc).success).toBe(true);
expect(safeValidateSchema(doc).success).toBe(true);
expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(true);

const adapter = aggregatingAdapter([{ close_date: '2026-01', amount: 42 }]);
mount(doc, adapter);

expect(await screen.findByText('42')).toBeTruthy();
expect(adapter.aggregate.mock.calls[0][0]).toBe('opportunity');
// The WHOLE bag, so a key added to or dropped from the call is red either way.
expect(bagOf(adapter)).toEqual({
groupBy: [MONTHLY],
aggregations: [{ function: 'sum', alias: 'amount', field: 'amount' }],
where: { stage: 'won' },
});
expect(Array.isArray(bagOf(adapter).where)).toBe(false);
expect(FilterConditionSchema.safeParse(bagOf(adapter).where).success).toBe(true);
});

it('lowers a multi-rule list to one condition, on the count shape the showcase tiles author', async () => {
// `app-showcase`'s My Work tiles count by `id` under a rule list; this is
// its Open Tasks tile with a structured `groupBy` and a second rule added.
// A count is summed across the buckets under the `count` alias.
const doc = node({
label: 'Open Tasks',
objectName: 'showcase_task',
aggregate: { field: 'id', function: 'count', groupBy: { field: 'created_at', dateGranularity: 'month' } },
filter: [
{ field: 'status', operator: 'not_equals', value: 'done' },
{ field: 'priority', operator: 'in', value: ['high', 'urgent'] },
],
});
expect(ObjectMetricBlockSchema.safeParse(doc).success).toBe(true);
expect(safeValidateSchema(doc).success).toBe(true);
expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(true);

const adapter = aggregatingAdapter([
{ created_at: '2026-01', count: 3 },
{ created_at: '2026-02', count: 4 },
]);
mount(doc, adapter);

expect(await screen.findByText('7')).toBeTruthy();
expect(bagOf(adapter).where).toEqual({
$and: [{ status: { $ne: 'done' } }, { priority: { $in: ['high', 'urgent'] } }],
});
});

it('lowers every operator the spec`s rule vocabulary declares to a FilterCondition', async () => {
// Read off the spec at run time, so an operator it adds is covered here
// without an edit. A comparand is supplied where the operator takes one.
const comparand: Record<string, unknown> = {
in: ['a', 'b'],
not_in: ['a', 'b'],
between: [1, 9],
greater_than: 5,
less_than: 5,
greater_than_or_equal: 5,
less_than_or_equal: 5,
before: '2026-01-01',
after: '2026-01-01',
};
const valueless = new Set(['is_empty', 'is_not_empty', 'is_null', 'is_not_null']);
expect(VIEW_FILTER_OPERATORS.length).toBeGreaterThan(0);

for (const operator of VIEW_FILTER_OPERATORS) {
const rule: Record<string, unknown> = { field: 'f', operator };
if (!valueless.has(operator)) rule.value = comparand[operator] ?? 'x';
const doc = node({
objectName: 'deal',
aggregate: { field: 'amount', function: 'sum', groupBy: MONTHLY },
filter: [rule],
});
expect(ObjectMetricBlockSchema.safeParse(doc).success, operator).toBe(true);
expect(safeValidateSchema(doc).success, operator).toBe(true);
expect(StrictAnyComponentSchema.safeParse(doc).success, operator).toBe(true);

const adapter = aggregatingAdapter([{ close_date: '2026-01', amount: 1 }]);
mount(doc, adapter);
await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled());
const where = bagOf(adapter).where;
expect(Array.isArray(where), operator).toBe(false);
expect(where, operator).toBeDefined();
expect(FilterConditionSchema.safeParse(where).success, operator).toBe(true);
cleanup();
}
});

it('an EMPTY rule list posts no filter, as the legacy wire posts none', async () => {
const adapter = aggregatingAdapter([{ close_date: '2026-01', amount: 5 }]);
mount(
node({ objectName: 'deal', aggregate: { field: 'amount', function: 'sum', groupBy: MONTHLY }, filter: [] }),
adapter,
);

await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled());
expect(bagOf(adapter).where).toBeUndefined();
});

it('BOUNDARY — an array the lowering cannot read is handed on, never dropped to "no filter"', async () => {
// A record condition wrapped in an array is neither a rule list nor
// filter AST. `parseFilterAST` answers `undefined` for it, which as
// `where` would aggregate EVERY row under a filtered question. It is
// handed on unparsed, so the adapter's refusal answers it instead.
const unreadable = [{ stage: 'won' }];
const adapter = aggregatingAdapter([{ close_date: '2026-01', amount: 5 }]);
mount(
node({ objectName: 'deal', aggregate: { field: 'amount', function: 'sum', groupBy: MONTHLY }, filter: unreadable }),
adapter,
);

await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled());
expect(bagOf(adapter).where).toEqual(unreadable);
});
});

describe('object-metric — the shapes that must NOT move (objectui#11526 controls)', () => {
it('CONTROL — a string `groupBy` still carries the rule list RAW on the legacy bag', async () => {
// The adapter lowers this wire's `filter` itself, so the producer leaves it
// alone. A producer that lowered it here too would be a second lowering.
const doc = node({
label: 'Won',
objectName: 'opportunity',
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: WON,
});
const adapter = aggregatingAdapter([{ amount_sum: 42 }]);
mount(doc, adapter);

expect(await screen.findByText('42')).toBeTruthy();
expect(bagOf(adapter)).toEqual({
field: 'amount',
function: 'sum',
groupBy: 'stage',
filter: WON,
});
});

it('CONTROL — a flat record filter still travels verbatim on the spec-shape query', async () => {
const record = { stage: 'won' };
const adapter = aggregatingAdapter([{ close_date: '2026-01', amount: 42 }]);
mount(
node({
label: 'Won',
objectName: 'opportunity',
aggregate: { field: 'amount', function: 'sum', groupBy: MONTHLY },
filter: record,
}),
adapter,
);

expect(await screen.findByText('42')).toBeTruthy();
expect(bagOf(adapter).where).toEqual(record);
});
});
Loading