Skip to content

Commit d5500b5

Browse files
committed
refactor(appkit-ui): extract MetricFilter vocabulary + toMetricFilter builder to /js
Move the twelve-operator MetricFilter grammar out of react/hooks/types.ts into a canonical, framework-agnostic js/metric-filter/ module and add a toMetricFilter builder that compiles a { dimension -> value(s) } shorthand into a MetricFilter (scalar -> equals, array -> in, omit undefined/empty). react/hooks/types.ts now re-exports the types so the /react public surface and UseMetricViewOptions.filter are unchanged. Wire the dev-playground metric-views route's buildFilter onto toMetricFilter, keeping only the app-specific cross-filter facet-exclusion local. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 21f66c7 commit d5500b5

5 files changed

Lines changed: 196 additions & 42 deletions

File tree

apps/dev-playground/client/src/routes/metric-views.route.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { formatLabel, formatValue } from "@databricks/appkit-ui/js";
1+
import {
2+
formatLabel,
3+
formatValue,
4+
type MetricFilter,
5+
toMetricFilter,
6+
} from "@databricks/appkit-ui/js";
27
import {
38
Badge,
49
BarChart,
@@ -10,8 +15,6 @@ import {
1015
CardTitle,
1116
DonutChart,
1217
LineChart,
13-
type MetricFilter,
14-
type MetricPredicate,
1518
Select,
1619
SelectContent,
1720
SelectItem,
@@ -66,20 +69,22 @@ const ALL = "__all__";
6669
* *cross*-filter rather than a global filter: the by-region chart keeps every
6770
* region visible when a region is selected (so you can pick another), while the
6871
* charts grouped by *other* dimensions narrow to that region.
72+
*
73+
* The map-to-`MetricFilter` compilation itself is the SDK's `toMetricFilter`
74+
* (from `@databricks/appkit-ui/js`) — this wrapper only adds the cross-filter
75+
* facet-exclusion, which is app-specific and stays local.
6976
*/
7077
function buildFilter(
7178
selection: Selection,
7279
exclude?: FilterDimension,
7380
): MetricFilter | undefined {
74-
const predicates: MetricPredicate[] = [];
81+
const shorthand: Record<string, string> = {};
7582
for (const dimension of FILTER_DIMENSIONS) {
7683
const value = selection[dimension];
7784
if (dimension === exclude || value === undefined) continue;
78-
predicates.push({ member: dimension, operator: "equals", values: [value] });
85+
shorthand[dimension] = value;
7986
}
80-
if (predicates.length === 0) return undefined;
81-
if (predicates.length === 1) return predicates[0];
82-
return { and: predicates };
87+
return toMetricFilter(shorthand);
8388
}
8489

8590
/**

packages/appkit-ui/src/js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export * from "./arrow";
1313
export * from "./config";
1414
export * from "./constants";
1515
export * from "./format";
16+
export * from "./metric-filter";
1617
export * from "./sse";
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, test } from "vitest";
2+
import { type MetricFilter, toMetricFilter } from "./index";
3+
4+
describe("toMetricFilter", () => {
5+
test("returns undefined for an empty selection", () => {
6+
expect(toMetricFilter({})).toBeUndefined();
7+
});
8+
9+
test("omits members with undefined values", () => {
10+
expect(toMetricFilter({ region: undefined })).toBeUndefined();
11+
expect(toMetricFilter({ region: undefined, segment: "SMB" })).toEqual({
12+
member: "segment",
13+
operator: "equals",
14+
values: ["SMB"],
15+
});
16+
});
17+
18+
test("omits members with empty-array values", () => {
19+
expect(toMetricFilter({ region: [] })).toBeUndefined();
20+
});
21+
22+
test("compiles a single scalar member to a bare equals predicate", () => {
23+
expect(toMetricFilter({ region: "EMEA" })).toEqual({
24+
member: "region",
25+
operator: "equals",
26+
values: ["EMEA"],
27+
});
28+
});
29+
30+
test("compiles a numeric scalar to an equals predicate", () => {
31+
expect(toMetricFilter({ tier: 2 })).toEqual({
32+
member: "tier",
33+
operator: "equals",
34+
values: [2],
35+
});
36+
});
37+
38+
test("compiles an array member to an in predicate", () => {
39+
expect(toMetricFilter({ region: ["EMEA", "APAC"] })).toEqual({
40+
member: "region",
41+
operator: "in",
42+
values: ["EMEA", "APAC"],
43+
});
44+
});
45+
46+
test("AND-groups multiple members, mixing equals and in", () => {
47+
expect(
48+
toMetricFilter({ region: ["EMEA", "APAC"], segment: "SMB" }),
49+
).toEqual({
50+
and: [
51+
{ member: "region", operator: "in", values: ["EMEA", "APAC"] },
52+
{ member: "segment", operator: "equals", values: ["SMB"] },
53+
],
54+
});
55+
});
56+
57+
test("copies array values rather than aliasing the caller's array", () => {
58+
const values = ["EMEA", "APAC"];
59+
const filter = toMetricFilter({ region: values });
60+
// A single member compiles to a bare predicate (has `values`), not a group.
61+
if (!filter || !("values" in filter)) {
62+
throw new Error("expected a leaf predicate with values");
63+
}
64+
expect(filter.values).toEqual(values);
65+
expect(filter.values).not.toBe(values);
66+
});
67+
68+
test("produces a MetricFilter assignable to the exported type", () => {
69+
const filter: MetricFilter | undefined = toMetricFilter({ region: "EMEA" });
70+
expect(filter).toBeDefined();
71+
});
72+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// ────────────────────────────────────────────────────────────────────────────
2+
// Metric filter vocabulary + builder.
3+
//
4+
// Pure, framework-agnostic. Lives on the `/js` axis because a `MetricFilter` is
5+
// plain data — a Node script, an SSR pass, or a test can build one without React
6+
// in the graph. The React `useMetricView` hook re-exports these types from
7+
// `@databricks/appkit-ui/react` so its public surface is unchanged.
8+
//
9+
// **Kept in sync with appkit `plugins/analytics/types.ts`** — appkit-ui cannot
10+
// depend on appkit, so this mirrors the twelve-operator filter grammar by hand.
11+
// ────────────────────────────────────────────────────────────────────────────
12+
13+
/** v1 filter operator vocabulary — exactly twelve names. */
14+
export type MetricFilterOperatorName =
15+
| "equals"
16+
| "notEquals"
17+
| "in"
18+
| "notIn"
19+
| "gt"
20+
| "gte"
21+
| "lt"
22+
| "lte"
23+
| "contains"
24+
| "notContains"
25+
| "set"
26+
| "notSet";
27+
28+
/** A single filter predicate — the leaf node of the recursive {@link MetricFilter} tree. */
29+
export interface MetricPredicate {
30+
member: string;
31+
operator: MetricFilterOperatorName;
32+
values?: ReadonlyArray<string | number>;
33+
}
34+
35+
/** Recursive filter expression: a leaf {@link MetricPredicate} or an `and`/`or` group. */
36+
export type MetricFilter =
37+
| MetricPredicate
38+
| { and: ReadonlyArray<MetricFilter> }
39+
| { or: ReadonlyArray<MetricFilter> };
40+
41+
/**
42+
* Shorthand map of `dimension -> selected value(s)` that {@link toMetricFilter}
43+
* compiles into a {@link MetricFilter}. A member is dropped when its value is
44+
* `undefined` or an empty array, so a partially-filled filter-bar selection maps
45+
* straight to "no predicate for that dimension".
46+
*/
47+
export type MetricFilterShorthand = Record<
48+
string,
49+
string | number | ReadonlyArray<string | number> | undefined
50+
>;
51+
52+
/**
53+
* Compile a `{ dimension -> value(s) }` shorthand into a {@link MetricFilter} —
54+
* the equality/membership case a filter bar, dropdown set, or clicked data point
55+
* produces. Scalar values become an `equals` predicate; array values become an
56+
* `in` predicate. Members with `undefined` or empty-array values are omitted.
57+
*
58+
* Returns a bare {@link MetricPredicate} for a single member, an `and` group for
59+
* several, and `undefined` when nothing is selected (so the caller can pass it
60+
* straight to `useMetricView`'s optional `filter`, which omits the field when
61+
* `undefined`). For operators beyond equality/membership (ranges, `contains`,
62+
* `set`), build the {@link MetricFilter} tree directly.
63+
*
64+
* @example
65+
* ```typescript
66+
* toMetricFilter({ region: "EMEA" });
67+
* // → { member: "region", operator: "equals", values: ["EMEA"] }
68+
*
69+
* toMetricFilter({ region: ["EMEA", "APAC"], segment: "SMB" });
70+
* // → { and: [
71+
* // { member: "region", operator: "in", values: ["EMEA", "APAC"] },
72+
* // { member: "segment", operator: "equals", values: ["SMB"] },
73+
* // ] }
74+
*
75+
* toMetricFilter({ region: undefined }); // → undefined
76+
* ```
77+
*/
78+
export function toMetricFilter(
79+
selection: MetricFilterShorthand,
80+
): MetricFilter | undefined {
81+
const predicates: MetricPredicate[] = [];
82+
for (const member of Object.keys(selection)) {
83+
const value = selection[member];
84+
if (value === undefined) continue;
85+
if (Array.isArray(value)) {
86+
if (value.length === 0) continue;
87+
predicates.push({ member, operator: "in", values: [...value] });
88+
} else {
89+
predicates.push({
90+
member,
91+
operator: "equals",
92+
values: [value as string | number],
93+
});
94+
}
95+
}
96+
if (predicates.length === 0) return undefined;
97+
if (predicates.length === 1) return predicates[0];
98+
return { and: predicates };
99+
}

packages/appkit-ui/src/react/hooks/types.ts

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -317,40 +317,17 @@ export type InferMetricRow<K> = K extends AugmentedRegistry<MetricRegistry>
317317
: Record<string, unknown>
318318
: Record<string, unknown>;
319319

320-
// ────────────────────────────────────────────────────────────────────────────
321-
// Metric filter vocabulary.
322-
//
323-
// **Kept in sync with appkit `plugins/analytics/types.ts`** — appkit-ui cannot
324-
// depend on appkit, so this mirrors the twelve-operator filter grammar by hand.
325-
// ────────────────────────────────────────────────────────────────────────────
326-
327-
/** v1 filter operator vocabulary — exactly twelve names. */
328-
export type MetricFilterOperatorName =
329-
| "equals"
330-
| "notEquals"
331-
| "in"
332-
| "notIn"
333-
| "gt"
334-
| "gte"
335-
| "lt"
336-
| "lte"
337-
| "contains"
338-
| "notContains"
339-
| "set"
340-
| "notSet";
341-
342-
/** A single filter predicate — the leaf node of the recursive {@link MetricFilter} tree. */
343-
export interface MetricPredicate {
344-
member: string;
345-
operator: MetricFilterOperatorName;
346-
values?: ReadonlyArray<string | number>;
347-
}
348-
349-
/** Recursive filter expression: a leaf {@link MetricPredicate} or an `and`/`or` group. */
350-
export type MetricFilter =
351-
| MetricPredicate
352-
| { and: ReadonlyArray<MetricFilter> }
353-
| { or: ReadonlyArray<MetricFilter> };
320+
// The metric-filter vocabulary is pure data (no React), so it lives canonically
321+
// on the `/js` axis. Re-export it here so the `/react` public surface — and
322+
// `UseMetricViewOptions.filter` below — is unchanged. The runtime builder
323+
// `toMetricFilter` is available from `@databricks/appkit-ui/js`.
324+
export type {
325+
MetricFilter,
326+
MetricFilterOperatorName,
327+
MetricPredicate,
328+
} from "@/js";
329+
330+
import type { MetricFilter } from "@/js";
354331

355332
/** Options for configuring a `useMetricView` query. */
356333
export interface UseMetricViewOptions<K extends MetricKey = MetricKey> {

0 commit comments

Comments
 (0)