diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index 86c086805..feed19476 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -13,6 +13,7 @@ import { SearchIcon, ServerIcon, ShieldIcon, + SigmaIcon, Wand2Icon, ZapIcon, } from "lucide-react"; @@ -64,6 +65,13 @@ export const NAV_GROUPS: ReadonlyArray = [ "Same dashboard — served over Apache Arrow streaming for zero-copy speed.", icon: ZapIcon, }, + { + to: "/metric-views", + label: "Metric Views", + description: + "Measure a governed UC metric view with useMetricView — labels and formats from injected metadata.", + icon: SigmaIcon, + }, { to: "/lakebase", label: "Lakebase", diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 94034f5f7..a57845549 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as SmartDashboardRouteRouteImport } from './routes/smart-dashboar import { Route as ServingRouteRouteImport } from './routes/serving.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' import { Route as PolicyMatrixRouteRouteImport } from './routes/policy-matrix.route' +import { Route as MetricViewsRouteRouteImport } from './routes/metric-views.route' import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' import { Route as JobsRouteRouteImport } from './routes/jobs.route' import { Route as GenieRouteRouteImport } from './routes/genie.route' @@ -69,6 +70,11 @@ const PolicyMatrixRouteRoute = PolicyMatrixRouteRouteImport.update({ path: '/policy-matrix', getParentRoute: () => rootRouteImport, } as any) +const MetricViewsRouteRoute = MetricViewsRouteRouteImport.update({ + id: '/metric-views', + path: '/metric-views', + getParentRoute: () => rootRouteImport, +} as any) const LakebaseRouteRoute = LakebaseRouteRouteImport.update({ id: '/lakebase', path: '/lakebase', @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/metric-views': typeof MetricViewsRouteRoute '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute @@ -158,6 +165,7 @@ export interface FileRoutesByTo { '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/metric-views': typeof MetricViewsRouteRoute '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute @@ -180,6 +188,7 @@ export interface FileRoutesById { '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/metric-views': typeof MetricViewsRouteRoute '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute @@ -203,6 +212,7 @@ export interface FileRouteTypes { | '/genie' | '/jobs' | '/lakebase' + | '/metric-views' | '/policy-matrix' | '/reconnect' | '/serving' @@ -224,6 +234,7 @@ export interface FileRouteTypes { | '/genie' | '/jobs' | '/lakebase' + | '/metric-views' | '/policy-matrix' | '/reconnect' | '/serving' @@ -245,6 +256,7 @@ export interface FileRouteTypes { | '/genie' | '/jobs' | '/lakebase' + | '/metric-views' | '/policy-matrix' | '/reconnect' | '/serving' @@ -267,6 +279,7 @@ export interface RootRouteChildren { GenieRouteRoute: typeof GenieRouteRoute JobsRouteRoute: typeof JobsRouteRoute LakebaseRouteRoute: typeof LakebaseRouteRoute + MetricViewsRouteRoute: typeof MetricViewsRouteRoute PolicyMatrixRouteRoute: typeof PolicyMatrixRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute ServingRouteRoute: typeof ServingRouteRoute @@ -335,6 +348,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PolicyMatrixRouteRouteImport parentRoute: typeof rootRouteImport } + '/metric-views': { + id: '/metric-views' + path: '/metric-views' + fullPath: '/metric-views' + preLoaderRoute: typeof MetricViewsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/lakebase': { id: '/lakebase' path: '/lakebase' @@ -427,6 +447,7 @@ const rootRouteChildren: RootRouteChildren = { GenieRouteRoute: GenieRouteRoute, JobsRouteRoute: JobsRouteRoute, LakebaseRouteRoute: LakebaseRouteRoute, + MetricViewsRouteRoute: MetricViewsRouteRoute, PolicyMatrixRouteRoute: PolicyMatrixRouteRoute, ReconnectRouteRoute: ReconnectRouteRoute, ServingRouteRoute: ServingRouteRoute, diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx new file mode 100644 index 000000000..f0a28f42a --- /dev/null +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -0,0 +1,682 @@ +import { + formatLabel, + formatValue, + type MetricFilter, + toMetricFilter, +} from "@databricks/appkit-ui/js"; +import { + Badge, + BarChart, + Button, + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, + DonutChart, + LineChart, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useMetricView, +} from "@databricks/appkit-ui/react"; +import { createFileRoute } from "@tanstack/react-router"; +import { FilterIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { Header } from "@/components/layout/header"; + +export const Route = createFileRoute("/metric-views")({ + component: MetricViewsRoute, +}); + +// Columns each visual asks the `revenue` metric view for. Declared at module +// scope so their array identities stay stable across renders — `useMetricView` +// serializes the request body, so this also keeps each SSE subscription from +// re-firing on unrelated state changes. Measure / dimension names and the row +// shape are inferred from the generated `MetricRegistry` augmentation +// (shared/appkit-types/metric-views.ts). +const REGION_DIM = ["region"] as const; +const SEGMENT_DIM = ["segment"] as const; +const TIME_DIM = ["created_at"] as const; +const ARR_MEASURE = ["arr"] as const; +const TREND_MEASURES = ["arr", "mrr"] as const; +const TREND_ORDER_BY = [{ field: "created_at", direction: "ASC" }] as const; +const TABLE_MEASURES = ["arr", "mrr", "new_arr", "churned_arr"] as const; +const TABLE_COLUMNS = ["region", ...TABLE_MEASURES] as const; +// Top-N needs an explicit `orderBy`: the route appends the grouped dimensions as +// tie-breakers whenever `limit` is set, which makes the page stable across +// reloads but not *ranked* — only ordering by the measure does that. +const TABLE_ORDER_BY = [{ field: "arr", direction: "DESC" }] as const; + +// The dimensions the page lets you slice by. The filter-bar dropdowns, the +// detail-table row click, AND the chart clicks (region bar / segment donut) +// all write selections keyed by these names, and every visual composes them +// into a `MetricFilter` the same way — one shared `selection` state drives them. +const FILTER_DIMENSIONS = ["region", "segment"] as const; +type FilterDimension = (typeof FILTER_DIMENSIONS)[number]; +// `null` is a real selection — "the rows where this dimension IS NULL" — and is +// distinct from a dimension being absent (no filter). `toMetricFilter` compiles +// it to `notSet`; stringifying it to "null" would instead build `equals 'null'` +// and match nothing. +type Selection = Partial>; + +// Radix `Select` *throws* on an empty-string item value (it reserves "" for +// clearing the selection), so explicit sentinels stand in for the three values +// that have no usable string form of their own: the "no filter on this +// dimension" choice, a NULL group key, and a genuine empty-string value. +const ALL = "__all__"; +const NONE = "__none__"; +const EMPTY = "__empty__"; + +/** Labels for the group keys that would otherwise render as nothing. */ +const NONE_LABEL = "(none)"; +const EMPTY_LABEL = "(empty)"; + +/** + * A dimension value as a `Select` item value: real values pass through, a NULL + * group key becomes the {@link NONE} sentinel, and `""` becomes {@link EMPTY}. + */ +function toItemValue(value: string | null): string { + if (value === null) return NONE; + return value === "" ? EMPTY : value; +} + +/** Inverse of {@link toItemValue} — maps the sentinels back to a selection. */ +function fromItemValue(value: string): string | null | undefined { + if (value === ALL) return undefined; + if (value === NONE) return null; + return value === EMPTY ? "" : value; +} + +/** + * Display label for a selected dimension value, naming the cases that render as + * nothing. `""` is a real value that filters on `equals ''` — distinct from the + * NULL group — so it gets its own label rather than sharing "(none)". + */ +function toDisplayLabel(value: string | null): string { + if (value === null) return NONE_LABEL; + return value === "" ? EMPTY_LABEL : value; +} + +/** + * A clicked chart category as a selection value. The chart data is pre-normalized + * by `normalizeChartDataForChart`, so category names are already sentinels: + * `NONE` means NULL, `EMPTY` means `""`, and other strings are literal values. + * Map sentinels back to a selection (`null` or real value), leaving literals as-is. + */ +function fromChartName(name: string): string | null { + if (name === NONE) return null; + if (name === EMPTY) return ""; + return name; +} + +/** + * The distinct values of one dimension across a breakdown's rows, for a dropdown + * domain. A NULL group key is kept as `null` (never `String(null)`), sorted last. + */ +function toDimensionOptions( + rows: Array> | null, + dimension: FilterDimension, +): (string | null)[] { + let hasNull = false; + const values = new Set(); + for (const row of rows ?? []) { + const value = row[dimension]; + if (value === null || value === undefined) hasNull = true; + else values.add(String(value)); + } + const sorted: (string | null)[] = Array.from(values).sort(); + if (hasNull) sorted.push(null); + return sorted; +} + +/** + * Transforms chart data by replacing the dimension field with sentinels so + * chart-click round-trips match the dropdown path. Replaces the dimension value + * with {@link NONE} for NULL, {@link EMPTY} for `""`, leaving other strings + * unchanged. Called before chart rendering; the chart then emits sentinel names + * in clicks, which `fromChartName` decodes back to selections. + */ +function normalizeChartDataForChart( + rows: Array> | null, + dimension: FilterDimension, +): Array> | null { + if (!rows) return rows; + return rows.map((row) => { + const value = row[dimension]; + let normalized: string; + if (value === null || value === undefined) { + normalized = NONE; + } else if (value === "") { + normalized = EMPTY; + } else { + normalized = String(value); + } + return { ...row, [dimension]: normalized }; + }); +} + +/** + * Compose the active selection into a `MetricFilter`, optionally excluding one + * dimension. Excluding a visual's own grouping dimension is what makes this a + * *cross*-filter rather than a global filter: the by-region chart keeps every + * region visible when a region is selected (so you can pick another), while the + * charts grouped by *other* dimensions narrow to that region. + * + * The map-to-`MetricFilter` compilation itself is the SDK's `toMetricFilter` + * (from `@databricks/appkit-ui/js`) — this wrapper only adds the cross-filter + * facet-exclusion, which is app-specific and stays local. + */ +function buildFilter( + selection: Selection, + exclude?: FilterDimension, +): MetricFilter | undefined { + const shorthand: Record = {}; + for (const dimension of FILTER_DIMENSIONS) { + const value = selection[dimension]; + if (dimension === exclude || value === undefined) continue; + shorthand[dimension] = value; + } + return toMetricFilter(shorthand); +} + +/** + * The dimensions actually shaping a card's data, given the shared selection and + * the card's own excluded dimension. Mirrors `buildFilter`'s facet-exclusion so + * the badge tells the truth per-card: the ARR-by-region card excludes `region`, + * so it never claims a region filter it deliberately ignores. + */ +function appliedDimensions( + selection: Selection, + exclude?: FilterDimension, +): FilterDimension[] { + return FILTER_DIMENSIONS.filter( + (dimension) => dimension !== exclude && selection[dimension] !== undefined, + ); +} + +/** + * Header badge that makes a card explicit about which filters shaped its data. + * Renders nothing when the card is unfiltered, so an unsliced card stays clean. + * Placed in `CardAction` (top-right of the header) via the caller. + */ +function FilterBadge({ + selection, + exclude, +}: { + selection: Selection; + exclude?: FilterDimension; +}) { + const applied = appliedDimensions(selection, exclude); + if (applied.length === 0) return null; + return ( + + + {applied + .map( + (dimension) => + `${formatLabel(dimension)}: ${toDisplayLabel( + selection[dimension] ?? null, + )}`, + ) + .join(" · ")} + + ); +} + +/** + * Initial / error / empty state shared by every visual card. Returns `null` + * once data has rows so the caller renders the visual. During a same-shape + * refetch, `useMetricView` keeps the previous rows available, so this leaves + * the existing visual mounted while the replacement query is loading. + * + * `data === null` means the query hasn't produced a result yet — on first mount + * `useMetricView` is `loading=false, data=null` for a frame before its effect + * fires `start()`. Treating that as the skeleton state (not "empty") avoids + * flashing "No results" before the query has even run. Error is checked first + * so a failed query still surfaces its message rather than a skeleton. + */ +function VisualStatus({ + error, + data, +}: { + error: string | null; + data: readonly unknown[] | null; +}) { + if (error) + return ( +
+ {error} +
+ ); + if (data === null) return ; + if (data.length === 0) + return ( +
+ No results for this selection. +
+ ); + return null; +} + +function MetricViewsRoute() { + // The single source of cross-filter truth. Every visual derives its query + // filter from this map, and every control (dropdowns, table rows) writes + // back into it — so all visuals stay coordinated through one piece of state. + const [selection, setSelection] = useState({}); + + const setDimension = useCallback( + (dimension: FilterDimension, value: string | null | undefined) => { + setSelection((previous) => { + const next = { ...previous }; + if (value === undefined) delete next[dimension]; + else next[dimension] = value; + return next; + }); + }, + [], + ); + + const clearAll = useCallback(() => setSelection({}), []); + + // One filter per visual, each excluding its own grouping dimension so the + // facet you're slicing on stays fully visible (see buildFilter). + const regionFilter = useMemo( + () => buildFilter(selection, "region"), + [selection], + ); + const segmentFilter = useMemo( + () => buildFilter(selection, "segment"), + [selection], + ); + // The trend groups by created_at, which isn't a filterable dimension, so it + // applies the full selection with nothing excluded. + const trendFilter = useMemo(() => buildFilter(selection), [selection]); + + // Revenue by region — also supplies the Region dropdown's options and the + // detail table's rows. + const region = useMetricView("revenue", { + measures: ARR_MEASURE, + dimensions: REGION_DIM, + filter: regionFilter, + }); + + // Revenue by segment — also supplies the Segment dropdown's options. + const segment = useMetricView("revenue", { + measures: ARR_MEASURE, + dimensions: SEGMENT_DIM, + filter: segmentFilter, + }); + + // ARR + MRR over time — the hero trend. + const trend = useMetricView("revenue", { + measures: TREND_MEASURES, + dimensions: TIME_DIM, + timeGrain: "month", + timeDimension: "created_at", + filter: trendFilter, + orderBy: TREND_ORDER_BY, + }); + + // Detail table, grouped by region. Shares the region bar's filter, so + // clicking a row narrows the other visuals without hiding the row you clicked. + // Ranked by ARR and capped, so the table shows the same top regions on every + // load instead of an arbitrary slice. + const table = useMetricView("revenue", { + measures: TABLE_MEASURES, + dimensions: REGION_DIM, + filter: regionFilter, + orderBy: TABLE_ORDER_BY, + limit: 10, + }); + + // Dropdown option domains, derived from the region/segment breakdowns. A NULL + // group key is preserved as `null` (not stringified to "null") so selecting it + // compiles to `IS NULL` rather than an `equals 'null'` that matches nothing. + const regionOptions = useMemo( + () => toDimensionOptions(region.data, "region"), + [region.data], + ); + const segmentOptions = useMemo( + () => toDimensionOptions(segment.data, "segment"), + [segment.data], + ); + + const activeDimensions = FILTER_DIMENSIONS.filter( + (dimension) => selection[dimension] !== undefined, + ); + + return ( +
+
+
+ + {/* Filter bar: dropdowns write into the shared selection. The Region + value is bound to selection.region, so it also reflects a table-row + click below. */} + + + Filters + + Slice every visual on this page by region and segment. + + + +
+ + + +
+ + {/* Active-filter chips — click to remove one, or clear all. */} + {activeDimensions.length > 0 && ( +
+ {activeDimensions.map((dimension) => ( + // `asChild` renders the Badge as a real + + ))} + +
+ )} +
+
+ +
+ {/* Revenue by region */} + + + ARR by region + + revenue · arr · grouped by region + + + {/* Excludes `region` — same facet-exclusion as this card's + filter, so it never claims the region slice it ignores. */} + + + + + + {!region.error && region.data && region.data.length > 0 && ( + + formatValue(value, region.metadata?.[field]?.format) + } + onDataClick={(d) => + setDimension("region", fromChartName(d.name)) + } + selected={ + // `undefined` (no filter on this dimension) must stay + // undefined — mapping it through `toItemValue` would emit + // the NONE sentinel and emphasize the NULL bar. + selection.region === undefined + ? undefined + : toItemValue(selection.region) + } + /> + )} + + + + {/* Revenue by segment */} + + + ARR by segment + + revenue · arr · grouped by segment + + + + + + + + {!segment.error && segment.data && segment.data.length > 0 && ( + + formatValue(value, segment.metadata?.[field]?.format) + } + onDataClick={(d) => + setDimension("segment", fromChartName(d.name)) + } + selected={ + selection.segment === undefined + ? undefined + : toItemValue(selection.segment) + } + /> + )} + + +
+ + {/* Hero trend — reshapes as filters narrow. */} + + + Recurring revenue over time + + revenue · measures {TREND_MEASURES.join(", ")} · grouped by month + + + {/* No `exclude` — the trend groups by time, so it applies the + full selection (both region and segment narrow it). */} + + + + + + {!trend.error && trend.data && trend.data.length > 0 && ( + + formatValue(value, trend.metadata?.[field]?.format) + } + // Seam for write-back: `datum` carries the clicked point's + // measure, x value and series, which is what an edit dialog + // would need to write a correction to the source table. + // TODO: build the write-back flow on top of this handler. + onDataClick={() => {}} + /> + )} + + + + {/* Detail table: click a row to cross-filter by that region. */} + + + Revenue detail by region + + Click a row to filter every visual by that region — click again + (or a chip above) to clear. + + + {/* Grouped by region, so it excludes `region` (same as the region + bar) — a segment filter still narrows it. */} + + + + + + {!table.error && table.data && table.data.length > 0 && ( +
+ + + + {TABLE_COLUMNS.map((column) => ( + + {formatLabel(column, table.metadata?.[column])} + + ))} + + + + {/* One row per region — region is the GROUP BY key, so + it's unique per row and safe as the React key. */} + {table.data.map((row) => { + // Keep a NULL group key as `null` so selecting the row + // filters on `IS NULL`; `String(row.region)` would build + // an `equals 'null'` that matches no row. + const rowRegion = + row.region === null || row.region === undefined + ? null + : String(row.region); + const isSelected = selection.region === rowRegion; + const toggle = () => + setDimension( + "region", + isSelected ? undefined : rowRegion, + ); + return ( + // The keeps its native `row` role (no role + // override — that would break table semantics for + // screen readers); its onClick is a mouse-only + // convenience. The real keyboard-accessible control is + // the button in the region cell below. + + {TABLE_COLUMNS.map((column) => + column === "region" ? ( + + + + ) : ( + + {formatValue( + row[column], + table.metadata?.[column]?.format, + )} + + ), + )} + + ); + })} + +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/dev-playground/config/metric-views/metadata.generated.json b/apps/dev-playground/config/metric-views/metadata.generated.json new file mode 100644 index 000000000..10b35f9be --- /dev/null +++ b/apps/dev-playground/config/metric-views/metadata.generated.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "metricViews": { + "customers": { + "measures": { + "active_accounts": { + "type": "bigint", + "display_name": "Active Accounts", + "format": "#,##0" + }, + "churn_rate": { + "type": "decimal", + "display_name": "Churn Rate" + }, + "avg_ltv": { + "type": "double", + "display_name": "Average LTV", + "format": "$#,##0.00" + } + }, + "dimensions": { + "segment": { + "type": "string", + "display_name": "Customer Segment" + }, + "region": { + "type": "string", + "display_name": "Region" + }, + "csm_email": { + "type": "string", + "display_name": "CSM Email" + } + } + }, + "revenue": { + "measures": { + "mrr": { + "type": "double", + "display_name": "Monthly Recurring Revenue", + "format": "$#,##0.00" + }, + "arr": { + "type": "double", + "display_name": "Annual Recurring Revenue", + "format": "$#,##0.00", + "description": "Annualized contract value across all active subscriptions" + }, + "new_arr": { + "type": "double", + "display_name": "New ARR", + "format": "$#,##0.00" + }, + "churned_arr": { + "type": "double", + "display_name": "Churned ARR", + "format": "$#,##0.00" + } + }, + "dimensions": { + "region": { + "type": "string", + "display_name": "Region" + }, + "segment": { + "type": "string", + "display_name": "Customer Segment" + }, + "created_at": { + "type": "timestamp_ltz", + "display_name": "Subscription Start" + } + } + } + } +} diff --git a/apps/dev-playground/shared/appkit-types/metric-views.d.ts b/apps/dev-playground/shared/appkit-types/metric-views.d.ts index 1c7fb87a9..dd2a069b9 100644 --- a/apps/dev-playground/shared/appkit-types/metric-views.d.ts +++ b/apps/dev-playground/shared/appkit-types/metric-views.d.ts @@ -9,19 +9,19 @@ declare module "@databricks/appkit-ui/react" { lane: "obo"; measures: { /** @sqlType bigint */ - "active_accounts": number; + "active_accounts": string | null; /** @sqlType decimal */ - "churn_rate": number; + "churn_rate": string | null; /** @sqlType double */ - "avg_ltv": number; + "avg_ltv": string | null; }; dimensions: { /** @sqlType string */ - "segment": string; + "segment": string | null; /** @sqlType string */ - "region": string; + "region": string | null; /** @sqlType string */ - "csm_email": string; + "csm_email": string | null; }; measureKeys: "active_accounts" | "churn_rate" | "avg_ltv"; dimensionKeys: "segment" | "region" | "csm_email"; @@ -65,21 +65,21 @@ declare module "@databricks/appkit-ui/react" { lane: "sp"; measures: { /** @sqlType double */ - "mrr": number; + "mrr": string | null; /** @sqlType double */ - "arr": number; + "arr": string | null; /** @sqlType double */ - "new_arr": number; + "new_arr": string | null; /** @sqlType double */ - "churned_arr": number; + "churned_arr": string | null; }; dimensions: { /** @sqlType string */ - "region": string; + "region": string | null; /** @sqlType string */ - "segment": string; + "segment": string | null; /** @sqlType timestamp_ltz @timeGrain day|hour|minute|month|quarter|week|year */ - "created_at": string; + "created_at": string | null; }; measureKeys: "mrr" | "arr" | "new_arr" | "churned_arr"; dimensionKeys: "region" | "segment" | "created_at"; diff --git a/apps/dev-playground/tests/metric-views.spec.ts b/apps/dev-playground/tests/metric-views.spec.ts new file mode 100644 index 000000000..60c40440b --- /dev/null +++ b/apps/dev-playground/tests/metric-views.spec.ts @@ -0,0 +1,459 @@ +import { expect, type Page, type Route, test } from "@playwright/test"; + +const METRIC_ROUTE = "**/api/analytics/metric/revenue"; + +type MetricRequest = { + measures: string[]; + dimensions?: string[]; + filter?: unknown; + timeGrain?: string; + timeDimension?: string; + orderBy?: Array<{ field: string; direction: string }>; + limit?: number; +}; + +const displayMetadata = { + region: { type: "string", display_name: "Region" }, + segment: { type: "string", display_name: "Customer Segment" }, + created_at: { type: "timestamp_ltz", display_name: "Subscription Start" }, + arr: { + type: "double", + display_name: "Annual Recurring Revenue", + format: "$#,##0.00", + }, + mrr: { + type: "double", + display_name: "Monthly Recurring Revenue", + format: "$#,##0.00", + }, + new_arr: { + type: "double", + display_name: "New ARR", + format: "$#,##0.00", + }, + churned_arr: { + type: "double", + display_name: "Churned ARR", + format: "$#,##0.00", + }, +} as const; + +function baselineRows(body: MetricRequest): Array> { + if (body.dimensions?.[0] === "segment") { + return [ + { segment: "Enterprise", arr: "1493550348" }, + { segment: "Mid", arr: "105133668" }, + { segment: "SMB", arr: "6163392" }, + ]; + } + + if (body.dimensions?.[0] === "created_at") { + return [ + { created_at: "2025-12-01T00:00:00.000Z", arr: "1200", mrr: "100" }, + { created_at: "2026-01-01T00:00:00.000Z", arr: "2400", mrr: "200" }, + ]; + } + + if (body.measures.length > 1) { + return [ + { + region: "AMER", + arr: "849732624", + mrr: "70811052", + new_arr: "0", + churned_arr: "0", + }, + { + region: "EMEA", + arr: "521785968", + mrr: "43482164", + new_arr: "0", + churned_arr: "0", + }, + { + region: "APAC", + arr: "233328816", + mrr: "19444068", + new_arr: "0", + churned_arr: "0", + }, + ]; + } + + return [ + { region: "APAC", arr: "233328816" }, + { region: "EMEA", arr: "521785968" }, + { region: "AMER", arr: "849732624" }, + ]; +} + +function scopedMetadata(body: MetricRequest) { + return Object.fromEntries( + [...body.measures, ...(body.dimensions ?? [])].map((field) => [ + field, + displayMetadata[field as keyof typeof displayMetadata], + ]), + ); +} + +async function fulfillMetric( + route: Route, + body: MetricRequest, + rows = baselineRows(body), +) { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + headers: { "cache-control": "no-cache" }, + body: `event: result\ndata: ${JSON.stringify({ + type: "result", + data: rows, + metadata: scopedMetadata(body), + })}\n\n`, + }); +} + +async function selectFilter( + page: Page, + index: number, + option: "AMER" | "APAC" | "EMEA" | "Enterprise" | "All regions", +) { + await page.getByRole("combobox").nth(index).click(); + await page.getByRole("option", { name: option, exact: true }).click(); +} + +test.describe("Metric Views playground", () => { + test("loads once, shows four skeletons, and renders metadata-formatted baseline data", async ({ + page, + }) => { + const requests: MetricRequest[] = []; + let releaseResponses: (() => void) | undefined; + const responseGate = new Promise((resolve) => { + releaseResponses = resolve; + }); + const consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + await page.route(METRIC_ROUTE, async (route) => { + const body = route.request().postDataJSON() as MetricRequest; + requests.push(body); + await responseGate; + await fulfillMetric(route, body); + }); + + await page.goto("/metric-views"); + await expect( + page.getByRole("heading", { name: "Metric Views" }), + ).toBeVisible(); + await expect.poll(() => requests.length).toBe(4); + await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(4); + await expect(page.getByText("No results for this selection.")).toHaveCount( + 0, + ); + + releaseResponses?.(); + await expect(page.getByRole("button", { name: "AMER" })).toBeVisible(); + + expect(requests).toHaveLength(4); + expect(requests.map((request) => request.dimensions)).toEqual([ + ["region"], + ["segment"], + ["created_at"], + ["region"], + ]); + await expect(page.getByRole("columnheader")).toHaveText([ + "Region", + "Annual Recurring Revenue", + "Monthly Recurring Revenue", + "New ARR", + "Churned ARR", + ]); + await expect(page.locator("tbody tr")).toHaveText([ + "AMER$849,732,624.00$70,811,052.00$0.00$0.00", + "EMEA$521,785,968.00$43,482,164.00$0.00$0.00", + "APAC$233,328,816.00$19,444,068.00$0.00$0.00", + ]); + await expect( + page.getByRole("img", { name: "Annual recurring revenue by region" }), + ).toBeVisible(); + await expect( + page.getByRole("img", { + name: "Annual recurring revenue by customer segment", + }), + ).toBeVisible(); + await expect( + page.getByRole("img", { + name: "Annual and monthly recurring revenue by month", + }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Open navigation menu" }).click(); + await page.getByRole("menuitem", { name: "Metric Views" }).click(); + await expect(page).toHaveURL(/\/metric-views$/); + expect(consoleErrors).toEqual([]); + }); + + test("cross-filters with scoped predicates and supports keyboard/table clearing", async ({ + page, + }) => { + const requests: MetricRequest[] = []; + await page.route(METRIC_ROUTE, async (route) => { + const body = route.request().postDataJSON() as MetricRequest; + requests.push(body); + await fulfillMetric(route, body); + }); + + await page.goto("/metric-views"); + await expect(page.getByRole("button", { name: "AMER" })).toBeVisible(); + requests.length = 0; + + await page.getByRole("combobox").first().focus(); + await page.keyboard.press("Enter"); + await page + .getByRole("option", { name: "AMER", exact: true }) + .press("Enter"); + await expect( + page.getByRole("button", { name: "Remove Region filter" }), + ).toContainText("Region: AMER"); + await expect.poll(() => requests.length).toBe(2); + + const segmentWithRegion = requests.find( + (request) => request.dimensions?.[0] === "segment", + ); + expect(segmentWithRegion?.filter).toEqual({ + member: "region", + operator: "equals", + values: ["AMER"], + }); + await expect(page.locator("tbody tr")).toHaveCount(3); + // Only the segment and trend cards apply the region predicate; the region + // chart and table deliberately exclude their own facet. + await expect(page.getByText("Region: AMER", { exact: true })).toHaveCount( + 2, + ); + + requests.length = 0; + await page.getByRole("combobox").nth(1).focus(); + await page.keyboard.press("Enter"); + await page + .getByRole("option", { name: "Enterprise", exact: true }) + .press("Space"); + await expect( + page.getByRole("button", { name: "Remove Segment filter" }), + ).toContainText("Segment: Enterprise"); + await expect.poll(() => requests.length).toBe(3); + + const regionRequest = requests.find( + (request) => + request.dimensions?.[0] === "region" && request.measures.length === 1, + ); + const tableRequest = requests.find( + (request) => request.measures.length === 4, + ); + const trendRequest = requests.find( + (request) => request.dimensions?.[0] === "created_at", + ); + const segmentPredicate = { + member: "segment", + operator: "equals", + values: ["Enterprise"], + }; + expect(regionRequest?.filter).toEqual(segmentPredicate); + expect(tableRequest?.filter).toEqual(segmentPredicate); + expect(trendRequest?.filter).toEqual({ + and: [ + { member: "region", operator: "equals", values: ["AMER"] }, + segmentPredicate, + ], + }); + + await page.getByRole("button", { name: "Clear all" }).click(); + await expect( + page.getByRole("button", { name: /Remove .* filter/ }), + ).toHaveCount(0); + + const amer = page.getByRole("button", { name: "AMER" }); + await amer.click(); + await expect(amer).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("button", { name: "Remove Region filter" }), + ).toBeVisible(); + await amer.click(); + await expect(amer).toHaveAttribute("aria-pressed", "false"); + await expect( + page.getByRole("button", { name: "Remove Region filter" }), + ).toHaveCount(0); + + await amer.click(); + const regionChip = page.getByRole("button", { + name: "Remove Region filter", + }); + await regionChip.focus(); + await page.keyboard.press("Space"); + await expect(regionChip).toHaveCount(0); + + await page.setViewportSize({ width: 390, height: 844 }); + const regionBox = await page + .getByRole("img", { name: "Annual recurring revenue by region" }) + .boundingBox(); + const segmentBox = await page + .getByRole("img", { + name: "Annual recurring revenue by customer segment", + }) + .boundingBox(); + expect(regionBox).not.toBeNull(); + expect(segmentBox).not.toBeNull(); + expect(segmentBox?.y).toBeGreaterThan( + (regionBox?.y ?? 0) + (regionBox?.height ?? 0), + ); + const tableScrolls = await page + .locator('[data-slot="table-container"]') + .evaluate((element) => element.scrollWidth > element.clientWidth); + expect(tableScrolls).toBe(true); + }); + + test("chart clicks update the shared dropdowns and chips", async ({ + page, + }) => { + await page.route(METRIC_ROUTE, async (route) => { + const body = route.request().postDataJSON() as MetricRequest; + if (body.measures.length === 1 && body.dimensions?.[0] === "region") { + await fulfillMetric(route, body, [ + { region: "APAC", arr: "100" }, + { region: "EMEA", arr: "100" }, + { region: "AMER", arr: "100" }, + ]); + return; + } + if (body.dimensions?.[0] === "segment") { + await fulfillMetric(route, body, [ + { segment: "Enterprise", arr: "100" }, + { segment: "Mid", arr: "100" }, + { segment: "SMB", arr: "100" }, + ]); + return; + } + await fulfillMetric(route, body); + }); + + await page.goto("/metric-views"); + const regionChart = page.getByRole("img", { + name: "Annual recurring revenue by region", + }); + await expect(regionChart).toBeVisible(); + // ECharts animates bars/slices on mount; click after their hit regions are + // at their final positions. + await page.waitForTimeout(1_500); + const regionCanvas = regionChart.locator("canvas"); + const regionBox = await regionCanvas.boundingBox(); + expect(regionBox).not.toBeNull(); + await regionCanvas.click({ + position: { + x: (regionBox?.width ?? 0) * 0.25, + y: (regionBox?.height ?? 0) * 0.78, + }, + }); + await expect(page.getByRole("combobox").first()).toHaveText("APAC"); + await expect( + page.getByRole("button", { name: "Remove Region filter" }), + ).toContainText("Region: APAC"); + + const segmentChart = page.getByRole("img", { + name: "Annual recurring revenue by customer segment", + }); + await page.waitForTimeout(1_000); + const segmentCanvas = segmentChart.locator("canvas"); + const segmentBox = await segmentCanvas.boundingBox(); + expect(segmentBox).not.toBeNull(); + await segmentCanvas.click({ + position: { + x: (segmentBox?.width ?? 0) * 0.41, + y: (segmentBox?.height ?? 0) * 0.5, + }, + }); + await expect(page.getByRole("combobox").nth(1)).toHaveText("SMB"); + await expect( + page.getByRole("button", { name: "Remove Segment filter" }), + ).toContainText("Segment: SMB"); + }); + + test("keeps stale rows mounted and ignores late responses during rapid filters", async ({ + page, + }) => { + const failedRequests: string[] = []; + page.on("requestfailed", (request) => { + if (request.url().includes("/api/analytics/metric/revenue")) { + failedRequests.push(request.failure()?.errorText ?? "unknown"); + } + }); + + await page.route(METRIC_ROUTE, async (route) => { + const body = route.request().postDataJSON() as MetricRequest; + const filterText = JSON.stringify(body.filter ?? null); + const delay = filterText.includes("AMER") + ? 450 + : filterText.includes("APAC") + ? 300 + : filterText.includes("EMEA") + ? 50 + : 0; + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + const finalRows = filterText.includes("EMEA") ? [] : baselineRows(body); + await fulfillMetric(route, body, finalRows); + }); + + await page.goto("/metric-views"); + await expect(page.getByRole("button", { name: "AMER" })).toBeVisible(); + + await selectFilter(page, 0, "AMER"); + await expect(page.locator("tbody tr")).toHaveCount(3); + await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0); + await selectFilter(page, 0, "APAC"); + await selectFilter(page, 0, "EMEA"); + + await expect(page.getByRole("combobox").first()).toHaveText("EMEA"); + await expect(page.getByText("No results for this selection.")).toHaveCount( + 2, + ); + await page.waitForTimeout(500); + await expect(page.getByText("No results for this selection.")).toHaveCount( + 2, + ); + expect( + failedRequests.filter((error) => error === "net::ERR_ABORTED").length, + ).toBeGreaterThanOrEqual(2); + }); + + test("renders empty and user-safe network error states", async ({ page }) => { + let empty = true; + let fail = false; + await page.route(METRIC_ROUTE, async (route) => { + if (fail) { + await route.abort("failed"); + return; + } + const body = route.request().postDataJSON() as MetricRequest; + await fulfillMetric(route, body, empty ? [] : baselineRows(body)); + }); + + await page.goto("/metric-views"); + await expect(page.getByText("No results for this selection.")).toHaveCount( + 4, + ); + + empty = false; + await page.reload(); + await expect(page.getByRole("button", { name: "AMER" })).toBeVisible(); + fail = true; + await selectFilter(page, 0, "AMER"); + await expect(page.getByRole("alert").first()).toContainText( + "Network error. Please check your connection.", + ); + + fail = false; + await page.reload(); + await expect(page.getByRole("button", { name: "AMER" })).toBeVisible(); + }); +}); diff --git a/biome.json b/biome.json index 26e9114fd..826b75319 100644 --- a/biome.json +++ b/biome.json @@ -20,7 +20,8 @@ "!**/*.gen.css", "!**/*.gen.ts", "!**/typedoc-sidebar.ts", - "!**/template" + "!**/template", + "!**/appkit-types/metric-views.ts" ] }, "formatter": { diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 7af50c81f..76924abea 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 849910, - "unpacked": 2972786 + "packed": 856371, + "unpacked": 2998738 }, "dist": { "total": { - "raw": 2959129, - "gzip": 993081 + "raw": 2985081, + "gzip": 1001459 }, "js": { - "raw": 877554, - "gzip": 305823 + "raw": 884887, + "gzip": 308455 }, "types": { - "raw": 322078, - "gzip": 111391 + "raw": 321999, + "gzip": 111358 }, "maps": { - "raw": 1748702, - "gzip": 572049 + "raw": 1767400, + "gzip": 577828 }, "css": { "raw": 0, @@ -31,22 +31,22 @@ "raw": 10795, "gzip": 3818 }, - "fileCount": 597 + "fileCount": 604 }, "entries": [ { "id": ".", - "gzip": 91969, + "gzip": 92986, "composition": { - "initialGzip": 89395, + "initialGzip": 90412, "lazyGzip": 2574, - "totalGzip": 91969, - "own": 291970, + "totalGzip": 92986, + "own": 295115, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 85297, + "gzip": 86314, "kind": "initial" }, { @@ -132,17 +132,17 @@ }, { "id": "./type-generator", - "gzip": 20413, + "gzip": 21477, "composition": { - "initialGzip": 20413, + "initialGzip": 21477, "lazyGzip": 0, - "totalGzip": 20413, - "own": 58752, + "totalGzip": 21477, + "own": 61924, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 20413, + "gzip": 21477, "kind": "initial" } ] @@ -153,25 +153,25 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 316123, - "unpacked": 1314670 + "packed": 349140, + "unpacked": 1420504 }, "dist": { "total": { - "raw": 1310724, - "gzip": 436932 + "raw": 1416558, + "gzip": 474354 }, "js": { - "raw": 370879, - "gzip": 123476 + "raw": 398367, + "gzip": 133326 }, "types": { - "raw": 213530, - "gzip": 77458 + "raw": 232757, + "gzip": 84824 }, "maps": { - "raw": 709455, - "gzip": 232652 + "raw": 768574, + "gzip": 252858 }, "css": { "raw": 16860, @@ -181,22 +181,22 @@ "raw": 0, "gzip": 0 }, - "fileCount": 476 + "fileCount": 494 }, "entries": [ { "id": "./js", - "gzip": 4254, + "gzip": 5294, "composition": { - "initialGzip": 4410, + "initialGzip": 5449, "lazyGzip": 50587, - "totalGzip": 54997, - "own": 11865, + "totalGzip": 56036, + "own": 14490, "nodeModules": 213288, "chunks": [ { "label": "index.js", - "gzip": 4290, + "gzip": 5329, "kind": "initial" }, { @@ -232,17 +232,17 @@ }, { "id": "./react", - "gzip": 47453, + "gzip": 49867, "composition": { - "initialGzip": 439562, + "initialGzip": 441976, "lazyGzip": 49772, - "totalGzip": 489334, - "own": 172143, - "nodeModules": 1403070, + "totalGzip": 491748, + "own": 179209, + "nodeModules": 1403020, "chunks": [ { "label": "index.js", - "gzip": 437412, + "gzip": 439826, "kind": "initial" }, { diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index cf3076889..ad23b952c 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -10,7 +10,7 @@ AppKit can automatically generate TypeScript types for your SQL queries, providi Generate type-safe TypeScript declarations for query keys, parameters, and result rows. -All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.ts` — a real source file rather than a `.d.ts` because it also carries a runtime `metricViewsMetadata` constant alongside the augmentation. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. +All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.d.ts` (metric-view types). A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. ## Vite plugin: `appKitTypesPlugin` @@ -84,7 +84,7 @@ npx @databricks/appkit generate-types --wait #### CI resilience: committed types as fallback -In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed type files** (`shared/appkit-types/analytics.d.ts` and, when Metric Views are configured, `shared/appkit-types/metric-views.ts`) as the fallback when the warehouse is unreachable. These generated files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete. +In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed type files** (`shared/appkit-types/analytics.d.ts` and, when Metric Views are configured, `shared/appkit-types/metric-views.d.ts`) as the fallback when the warehouse is unreachable. These generated files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete. The generator **never overwrites committed types with degraded (`result: unknown`) types** — it writes real types, or it does not write at all. @@ -95,17 +95,18 @@ A **two-bucket failure taxonomy** determines whether the build crashes or falls The loud warning is a single greppable stderr line naming the coarse cause (auth blocked / warehouse unreachable / warehouse unavailable) and the warehouse ID, so CI logs surface that the build fell back to committed types. -For a Metric Views app, `metric-views.ts` must already exist before an environmental failure can fall back successfully. Unlike a declaration-only artifact, this file also exports the runtime `metricViewsMetadata` value consumed by the server, so `analytics.d.ts` alone cannot satisfy the gate. +For a Metric Views app, `metric-views.d.ts` must already exist before an environmental failure can fall back successfully — `analytics.d.ts` alone cannot satisfy the gate. The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`. ## Metric-view types -`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/metric-views/definitions.json` file is present, the same run that generates your query types also DESCRIBEs each declared [UC Metric View](../plugins/analytics.md) and writes `metric-views.ts` into `shared/appkit-types/`: +`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/metric-views/definitions.json` file is present, the same run that generates your query types also DESCRIBEs each declared [UC Metric View](../plugins/analytics.md) and writes two artifacts: -- `metric-views.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level. The same file also exports a runtime `metricViewsMetadata` constant carrying that metadata as a value — inject it via `analytics({ metricViewsMetadata })` so the [metric route](../plugins/analytics.md#metric-views) can attach per-column display metadata to its response payload. +- `shared/appkit-types/metric-views.d.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level. Selected row keys use the actual JSON_ARRAY wire value type (`string | null`); the SQL type remains available in metadata for deliberate parsing/formatting. +- `config/metric-views/metadata.generated.json` — the runtime half of the same pass, carrying that per-column metadata as a value beside your hand-authored `definitions.json`. The [metric route](../plugins/analytics.md#metric-views) discovers it automatically and attaches the requested columns' metadata to its response payload, so no plugin wiring is needed. Commit it with your generated types; it is generated, so do not hand-edit it. -If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. +If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.d.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. `definitions.json` is keyed by metric key; each entry names the three-part UC FQN of the view and, optionally, the executor it runs as (`app_service_principal`, the default, or `user`): diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index a2ca889a2..be969696d 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -129,6 +129,7 @@ Content-Type: application/json "timeGrain": "month", "timeDimension": "order_date", "filter": { "member": "region", "operator": "in", "values": ["EMEA", "APAC"] }, + "orderBy": [{ "field": "revenue", "direction": "DESC" }], "limit": 100 } ``` @@ -142,6 +143,7 @@ Content-Type: application/json | `filter` | object | no | Structured predicate tree translated into a parameterized `WHERE` clause (see [Filters](#filters)). | | `timeGrain` | `string` | no | Bucket a time dimension via `date_trunc('', …)` — e.g. `day`, `month`. Requires `timeDimension`. | | `timeDimension` | `string` | no | The single dimension `timeGrain` buckets. Must be one of `dimensions`. Required whenever `timeGrain` is set. | +| `orderBy` | array | no | Array of `{field, direction}` sort keys (max 20). `field` must be a selected measure or dimension. `direction` is `"ASC"` (default, omitted from SQL) or `"DESC"`. Order measures by their SELECT alias. | | `limit` | `number` | no | Positive integer row cap (max 100000). | | `format` | `string` | no | `JSON_ARRAY` (default). `JSON` is accepted as a deprecated alias for it; Arrow formats (`ARROW`, `ARROW_STREAM`) are rejected on this route. | @@ -157,11 +159,28 @@ SELECT MEASURE(`arr`) AS `arr`, MEASURE(`revenue`) AS `revenue`, FROM `catalog`.`schema`.`revenue_metrics` WHERE `region` IN (:f_0, :f_1) GROUP BY ALL +ORDER BY `revenue` DESC, `order_date`, `region` LIMIT 100 ``` The metric view's FQN and every measure/dimension identifier are backtick-quoted; filter values are bound as parameters (`:f_0`, `:f_1`, …), never interpolated into the SQL string. +### Deterministic results with `limit` + +When `limit` is set, the route automatically appends all grouped dimensions to the `ORDER BY` clause as tie-breakers (unless they are already named in `orderBy`). Under `GROUP BY ALL`, the full dimension tuple is unique per row, so ordering by all dimensions produces a **TOTAL order** — every run returns the same rows, not an arbitrary sample. + +This matters because `LIMIT` without `ORDER BY` is a row *sample*, not "the top n": Spark returns whichever rows it produced first, which varies with partitioning, parallelism and cache state. A card built on such a request can show a different number run to run with nothing erroring. The tie-breakers close that gap — over unchanged data, the same request now returns the same rows. + +If you want **top-N by a measure**, order that measure explicitly and provide `limit`: + +```json +{ "orderBy": [{ "field": "revenue", "direction": "DESC" }], "limit": 100 } +``` + +The route adds the remaining dimensions (`order_date`, `region` in the example above) after your explicit entry, so the result is stable across runs. + +**Important:** order measures by their **SELECT alias**. Spark rejects `ORDER BY MEASURE(\`revenue\`)` with `METRIC_VIEW_INVALID_MEASURE_FUNCTION_INPUT`. The generated SQL aliases every measure (e.g. `MEASURE(\`revenue\`) AS \`revenue\``), so always reference the alias — in this case, just `"revenue"`. + ### Filters `filter` is a recursive tree. A leaf is a single predicate: @@ -290,7 +309,7 @@ If the configured SQL warehouse is `STOPPED` or `STARTING` when a query is reque 2. Poll the warehouse state and stream `warehouse_status` events over SSE until it reaches `RUNNING`. 3. Execute the SQL statement. -This means a cold start no longer freezes the UI on a stalled spinner. Render the new `warehouseStatus` field to give users feedback: +This means a cold start no longer freezes the UI on a stalled spinner. Both `useAnalyticsQuery` and `useMetricView` expose the latest status for their current request through `warehouseStatus`; render it to give users feedback: ```tsx import { useAnalyticsQuery } from "@databricks/appkit-ui/react"; @@ -310,7 +329,7 @@ function SpendTable() { } ``` -`warehouseStatus` is `null` until the first status event arrives. After the server has observed the warehouse `RUNNING` once, subsequent requests within ~30s skip the readiness check entirely and `warehouseStatus` stays `null`, so the steady-state hot path isn't taxed any extra round-trips. +For both hooks, `warehouseStatus` resets to `null` when a request starts and remains there until the first status event arrives. After the server has observed the warehouse `RUNNING` once, subsequent requests within ~30s skip the readiness check entirely and `warehouseStatus` stays `null`, so the steady-state hot path isn't taxed any extra round-trips. If the warehouse is `DELETED`/`DELETING` or fails to reach `RUNNING` within the configured timeout, the route emits an `error` event (surfaced via the `error` field). @@ -336,7 +355,7 @@ export function AppShell({ children }) { } ``` -`useAnalyticsQuery` registers itself with the nearest provider, so no per-chart wiring is needed. The indicator renders only the `` mount point while every resource is healthy; it pops a single sticky toast — `toast.loading` for cold starts, `toast.error` for unrecoverable states — keyed by the worst kind, and dismisses it when they all settle. Because the same provider is shared across resource kinds (warehouse, lakebase, model serving, …), a single indicator covers every plugin. +`useAnalyticsQuery` and `useMetricView` register themselves with the nearest provider, so no per-chart wiring is needed. The indicator renders only the `` mount point while every resource is healthy; it pops a single sticky toast — `toast.loading` for cold starts, `toast.error` for unrecoverable states — keyed by the worst kind, and dismisses it when they all settle. Because the same provider is shared across resource kinds (warehouse, lakebase, model serving, …), a single indicator covers every plugin. If you already render your own `` for unrelated app toasts, drop the indicator and call `useResourceStatusToaster()` instead so resource-status toasts share that single Toaster: @@ -493,3 +512,251 @@ const { data } = useAnalyticsQuery("users", params); // Bad - creates a new object every render, causing infinite refetches const { data } = useAnalyticsQuery("users", { status: sql.string("active") }); ``` + +### useMetricView + +React hook that measures a [metric view](#metric-views) over SSE — the client twin of `POST /api/analytics/metric/:key`. Instead of writing SQL, you pass the measures, dimensions, and filter as a structured request; the hook streams back rows with typed column names plus per-column display metadata. + +```ts +import { useMetricView } from "@databricks/appkit-ui/react"; + +const { data, loading, error, errorCode, metadata, warehouseStatus } = + useMetricView("revenue", { + measures: ["arr", "mrr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + orderBy: [{ field: "created_at", direction: "ASC" }], + }); +``` + +When `"revenue"` is a key in the generated `MetricRegistry` (see [Metric-view types](../development/type-generation.md#metric-view-types)), the measure/dimension names, the allowed `timeGrain` values, and the selected row keys are all inferred — passing an unknown measure is a type error. JSON_ARRAY preserves SQL scalar cells as strings and allows SQL NULL for every column, so `data` is typed as `Array<{ arr: string | null; mrr: string | null; created_at: string | null }> | null`; use `metadata[col].type` when intentionally parsing a value. + +Time-series queries should explicitly order their selected time dimension ascending, as above. SQL result order is otherwise unspecified; chart helpers may normalize chronological data defensively, but consumers should not rely on that for query ordering. + +**Options:** + +| Option | Type | Required | Description | +| --------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------- | +| `measures` | `string[]` | yes | Measures to aggregate. Inferred from `MetricRegistry[key].measureKeys` for a known key. | +| `dimensions` | `string[]` | no | Dimensions to group by. Inferred from `measureKeys` / `dimensionKeys`. | +| `filter` | `MetricFilter` | no | Recursive predicate tree (same grammar as the route — see [Filters](#filters)). | +| `timeGrain` | `string` | no | Bucket a time dimension (`day`, `month`, …). Requires `timeDimension`. Inferred `timeGrains`. | +| `timeDimension` | `string` | no | The single dimension `timeGrain` buckets. Must be one of `dimensions`. | +| `orderBy` | `{field, direction?}[]` | no | Sort keys. `field` is narrowed to the measures/dimensions this call selected, so ordering by an unselected column is a type error. See [Deterministic results with `limit`](#deterministic-results-with-limit). | +| `limit` | `number` | no | Positive integer row cap. | +| `autoStart` | `boolean` | no | Start the metric query automatically. Defaults to `true`; set to `false` to defer it until the option becomes `true`. | + +**Return type:** + +```ts +{ + data: T | null; // selected row keys with JSON_ARRAY string | null values + loading: boolean; // true while the metric query is executing + error: string | null; // sanitized human-readable message, or null on success + errorCode: string | null; // stable upstream code (branch on this, not the message) + metadata: Record | undefined; // per-column display metadata (see below) + warehouseStatus: WarehouseStatus | null; // latest readiness status for the current request +} +``` + +Like `useAnalyticsQuery`, the option object is serialized (`JSON.stringify`) internally, so object/array literals passed fresh each render do **not** trigger a refetch as long as they serialize to the same string — you do **not** need to `useMemo` the options. (This is same-serialization, not deep structural equality: reordering keys within `filter` changes the string and does re-query. Hoisting `measures`/`dimensions` to module scope or memoizing is still fine, and keeps the arrays type-narrowed to their literal tuple.) + +`metadata` is the per-column display metadata for **only the columns you queried**, scoped and carried in the SSE `result` payload. It is `undefined` when the server resolved no metadata (the metric key is unknown, or types have not been generated) — so always treat it as optional. + +### Metadata + +The metric route stamps per-column display metadata (`display_name`, `format`, `type`, `description`) onto each `result` message. This metadata is **build-generated** by the metric-view type generator, which writes it to `config/metric-views/metadata.generated.json` beside your hand-authored `definitions.json`. + +**No wiring required.** The plugin discovers the bundle the same way it discovers `definitions.json`, so `analytics({})` is enough: + +```ts +// server/index.ts +import { analytics, createApp, server } from "@databricks/appkit"; + +createApp({ + plugins: [ + server(), + analytics({}), + // … + ], +}); +``` + +Commit `metadata.generated.json` alongside your generated types — it is the runtime half of the same generation pass, and the route reads it from disk at request time. + +This is **pure response decoration**: the metadata never enters the cache key and never changes the SQL. Every metric `result` message carries a `metadata` field scoped to the requested columns; when no bundle is present the message is byte-identical to a plain `/query` result and the hook's `metadata` is `undefined`. A missing or malformed bundle degrades to unlabeled columns and logs a warning — it never fails the query. Because the metadata rides on the payload, the client never has to import the generated file or hardcode a format string — it is **payload-carried and client-agnostic**. + +To bypass the file entirely — an app that builds its metadata some other way, or pins it deliberately — pass `analytics({ metricViewsMetadata })`. An explicit value always wins over the discovered bundle. + +### Format utilities + +`@databricks/appkit-ui/js` ships small, pure, tree-shakeable formatters that turn raw values + the metadata above into display strings. They take the format spec (or `MetricViewColumnDisplay`) as **arguments** — no React, no chart-library coupling — so they work in tables, tooltips, and chart configs alike. + +| Function | Purpose | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `formatValue(value, format?)` | Format a raw value with a UC/spreadsheet format spec (`"$#,##0.00"`, `"#,##0"`, `"0.0%"`). No spec → sensible default. | +| `formatLabel(name, columnMeta?)` | Human label for a column: prefers `columnMeta.display_name`, else humanizes the raw name. | +| `toD3Format(format?)` | Split a UC format into a [d3-format](https://d3js.org/d3-format) `specifier` and literal currency `prefix`. | + +The golden rule: **source the format from `metadata`, never hand-type it.** When `metadata` is `undefined`, `metadata?.[col]?.format` is `undefined` and `formatValue` degrades gracefully to a default: + +```tsx +import { formatLabel, formatValue } from "@databricks/appkit-ui/js"; +import { useMetricView } from "@databricks/appkit-ui/react"; + +function RevenueTable() { + const { data, metadata } = useMetricView("revenue", { + measures: ["arr", "mrr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + orderBy: [{ field: "created_at", direction: "ASC" }], + }); + const columns = ["created_at", "arr", "mrr"] as const; + + return ( + + + + {columns.map((col) => ( + // Header text from display_name (or a humanized fallback). + + ))} + + + + {data?.map((row, i) => ( + + {columns.map((col) => ( + // Format string comes from metadata, never hand-typed. + + ))} + + ))} + +
{formatLabel(col, metadata?.[col])}
{formatValue(row[col], metadata?.[col]?.format)}
+ ); +} +``` + +#### Feeding the format into charts + +Because `metadata[col].format` is just a string on the payload, the same spec drives axis ticks and tooltips in any chart library. + +**AppKit charts** — pass a `valueFormatter` to the built-in chart. The second argument is the measure field, so one callback can select the catalog format for each series. The chart applies it to its built-in value axis and per-series tooltips without replacing the internal ECharts `yAxis` or `tooltip` defaults: + +```tsx +import { formatValue } from "@databricks/appkit-ui/js"; +import { LineChart, useMetricView } from "@databricks/appkit-ui/react"; + +function RevenueChart() { + const { data, metadata } = useMetricView("revenue", { + measures: ["arr", "mrr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + orderBy: [{ field: "created_at", direction: "ASC" }], + }); + + if (!data) return null; + + return ( + + formatValue(value, metadata?.[field]?.format) + } + /> + ); +} +``` + +When multiple series share one value axis, its ticks use the first `yKey`; each tooltip uses the matching series field. + +The `selected` prop adds declarative emphasis only to bar, pie, and donut charts. Line, area, scatter, heatmap, and radar charts ignore it because category-selection semantics are not defined for those chart types. + +**[Plotly](https://plotly.com/javascript/)** — pass the numeric specifier as `tickformat` and the literal currency symbol as `tickprefix`. Keeping them separate is necessary because d3's `$` marker is locale-driven and cannot represent arbitrary symbols: + +```tsx +import Plot from "react-plotly.js"; +import { toD3Format } from "@databricks/appkit-ui/js"; +import { useMetricView } from "@databricks/appkit-ui/react"; + +function RevenuePlot() { + const { data, metadata } = useMetricView("revenue", { + measures: ["arr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + orderBy: [{ field: "created_at", direction: "ASC" }], + }); + const arrFormat = toD3Format(metadata?.arr?.format); + // "€#,##0.00" → { specifier: ",.2f", prefix: "€" } + + return ( + r.created_at) ?? [], + y: data?.map((r) => r.arr) ?? [], + name: metadata?.arr?.display_name ?? "arr", + }, + ]} + layout={{ + yaxis: { + tickformat: arrFormat?.specifier, + tickprefix: arrFormat?.prefix, + }, + hoverlabel: { namelength: -1 }, + }} + /> + ); +} +``` + +**[ECharts](https://echarts.apache.org/)** — use the format spec inside `axisLabel.formatter` / `tooltip.formatter` via `formatValue`: + +```tsx +import ReactECharts from "echarts-for-react"; +import { formatLabel, formatValue } from "@databricks/appkit-ui/js"; +import { useMetricView } from "@databricks/appkit-ui/react"; + +function RevenueECharts() { + const { data, metadata } = useMetricView("revenue", { + measures: ["arr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + orderBy: [{ field: "created_at", direction: "ASC" }], + }); + const arrFormat = metadata?.arr?.format; + + const option = { + xAxis: { type: "category", data: data?.map((r) => r.created_at) ?? [] }, + yAxis: { + type: "value", + axisLabel: { formatter: (v: number) => formatValue(v, arrFormat) }, + }, + tooltip: { + trigger: "axis", + valueFormatter: (v: number) => formatValue(v, arrFormat), + }, + series: [ + { + name: formatLabel("arr", metadata?.arr), + type: "line", + data: data?.map((r) => r.arr) ?? [], + }, + ], + }; + + return ; +} +``` + +In both cases the format string originates from the server-injected `metadata` and is never written into the component — swapping the YAML `format` attribute on the metric view re-flows every axis, tooltip, and table cell without a client change. diff --git a/package.json b/package.json index 3c63d7748..0d4b86314 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,8 @@ "overrides": { "@opentelemetry/core@<2.8.0": "2.8.0", "protobufjs@<7.6.2": "7.6.2", - "qs@<6.15.2": "6.15.2" + "qs@<6.15.2": "6.15.2", + "size-sensor": "1.0.3" } } } diff --git a/packages/appkit-ui/src/js/format/index.test.ts b/packages/appkit-ui/src/js/format/index.test.ts new file mode 100644 index 000000000..0bdfaa365 --- /dev/null +++ b/packages/appkit-ui/src/js/format/index.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "vitest"; +import { formatLabel, formatValue, toD3Format } from "./index"; + +describe("js/format formatValue", () => { + test("currency spec formats with prefix, grouping and 2 decimals", () => { + expect(formatValue(1234.5, "$#,##0.00")).toBe("$1,234.50"); + }); + + test("currency spec handles negatives with sign before the symbol", () => { + expect(formatValue(-1234.5, "$#,##0.00")).toBe("-$1,234.50"); + }); + + test("integer spec groups thousands with no decimals", () => { + expect(formatValue(1234567, "#,##0")).toBe("1,234,567"); + }); + + test("decimal grouping spec keeps N decimals", () => { + expect(formatValue(1234.5, "#,##0.00")).toBe("1,234.50"); + }); + + test("percent spec multiplies by 100 and appends %", () => { + expect(formatValue(0.1234, "0.0%")).toBe("12.3%"); + }); + + test("integer percent spec has no decimals", () => { + expect(formatValue(0.5, "0%")).toBe("50%"); + }); + + test("accepts numeric strings", () => { + expect(formatValue("1234.5", "$#,##0.00")).toBe("$1,234.50"); + }); + + test("accepts bigint values", () => { + expect(formatValue(1234567n, "#,##0")).toBe("1,234,567"); + }); + + test("preserves bigint precision beyond 2^53 (no Number() rounding)", () => { + // 9_007_199_254_740_993n = 2^53 + 1, which is NOT representable as a JS + // number — Number(bigint) would round it to 9_007_199_254_740_992. + expect(formatValue(9_007_199_254_740_993n, "#,##0")).toBe( + "9,007,199,254,740,993", + ); + expect(formatValue(9_007_199_254_740_993n, "$#,##0")).toBe( + "$9,007,199,254,740,993", + ); + expect(formatValue(-9_007_199_254_740_993n, "$#,##0")).toBe( + "-$9,007,199,254,740,993", + ); + }); + + // The JSON_ARRAY wire path delivers numeric cells as strings, so an int64 / + // large DECIMAL measure reaches the formatter as an integer-shaped string. + // Routing it through Number() would round it before formatting. + test("preserves precision for integer strings beyond 2^53", () => { + expect(formatValue("9007199254740993", "#,##0")).toBe( + "9,007,199,254,740,993", + ); + expect(formatValue("9007199254740993", "$#,##0")).toBe( + "$9,007,199,254,740,993", + ); + expect(formatValue("-9007199254740993", "$#,##0")).toBe( + "-$9,007,199,254,740,993", + ); + }); + + test("formats safe-range integer strings unchanged", () => { + expect(formatValue("1234567", "#,##0")).toBe("1,234,567"); + expect(formatValue("1234567", "#,##0.00")).toBe("1,234,567.00"); + }); + + test("preserves precision for fractional strings beyond 2^53", () => { + expect(formatValue("12345678901234567.89", "$#,##0.00")).toBe( + "$12,345,678,901,234,567.89", + ); + expect(formatValue("-12345678901234567.89", "R$#,##0.00")).toBe( + "-R$12,345,678,901,234,567.89", + ); + // Fixed-scale DECIMAL values must stay exact even when the display format + // omits their zero fractional digits. + expect(formatValue("12345678901234567.00", "¥#,##0")).toBe( + "¥12,345,678,901,234,567", + ); + }); + + test("rounds exact decimal strings to the requested display scale", () => { + expect(formatValue("12345678901234567.894", "$#,##0.00")).toBe( + "$12,345,678,901,234,567.89", + ); + expect(formatValue("12345678901234567.895", "$#,##0.00")).toBe( + "$12,345,678,901,234,567.90", + ); + expect(formatValue("-12345678901234567.895", "$#,##0.00")).toBe( + "-$12,345,678,901,234,567.90", + ); + expect(formatValue("9999999999999999.995", "$#,##0.00")).toBe( + "$10,000,000,000,000,000.00", + ); + }); + + test("multiplies decimal-string percentages without losing precision", () => { + expect(formatValue("12345678901234567.891", "0.00%")).toBe( + "1234567890123456789.10%", + ); + }); + + test("leaves exponent strings on the float path", () => { + expect(formatValue("1e3", "#,##0")).toBe("1,000"); + }); + + test("no format falls back to toLocaleString for numbers", () => { + expect(formatValue(1234.5)).toBe((1234.5).toLocaleString()); + }); + + test("no format passes through strings", () => { + expect(formatValue("hello")).toBe("hello"); + }); + + test("null and undefined become empty string", () => { + expect(formatValue(null)).toBe(""); + expect(formatValue(undefined)).toBe(""); + expect(formatValue(null, "$#,##0.00")).toBe(""); + }); + + test("non-numeric value with numeric spec falls back to String()", () => { + expect(formatValue("N/A", "#,##0")).toBe("N/A"); + }); + + // End-to-end over the currency symbols the metric-view generator emits + // (mv-registry/describe.ts CURRENCY_SYMBOLS + the unknown-code fallback). + // Each spec here is exactly what the generator produces for that symbol. + describe("preserves every currency symbol the generator emits", () => { + test.each([ + ["$#,##0.00", 1234.5, "$1,234.50"], // USD + ["€#,##0.00", 1234.5, "€1,234.50"], // EUR + ["£#,##0.00", 1234.5, "£1,234.50"], // GBP + ["¥#,##0", 1234, "¥1,234"], // JPY / CNY + ["₹#,##0.00", 1234.5, "₹1,234.50"], // INR + ["R$#,##0.00", 1234.5, "R$1,234.50"], // BRL (multi-char symbol) + ["XYZ #,##0.00", 1234.5, "XYZ 1,234.50"], // unknown ISO code + space + ])("formatValue(%s) preserves the symbol", (spec, value, expected) => { + expect(formatValue(value, spec)).toBe(expected); + }); + + test("negative currency keeps the sign before the symbol for every prefix", () => { + expect(formatValue(-1234.5, "€#,##0.00")).toBe("-€1,234.50"); + expect(formatValue(-1234.5, "R$#,##0.00")).toBe("-R$1,234.50"); + }); + }); +}); + +describe("js/format formatLabel", () => { + test("display_name wins over the raw name", () => { + const meta = { type: "double", display_name: "Avg LTV" }; + expect(formatLabel("avg_ltv", meta)).toBe("Avg LTV"); + }); + + test("humanizes snake_case when no display_name", () => { + expect(formatLabel("avg_ltv")).toBe("Avg Ltv"); + }); + + test("humanizes camelCase", () => { + expect(formatLabel("totalSpend")).toBe("Total Spend"); + }); + + test("humanizes ALL_CAPS", () => { + expect(formatLabel("TOTAL_SPEND")).toBe("Total Spend"); + }); + + test("columnMeta without display_name falls back to humanize", () => { + expect(formatLabel("user_name", { type: "string" })).toBe("User Name"); + }); +}); + +describe("js/format toD3Format", () => { + test("maps the common numeric specs", () => { + expect(toD3Format("$#,##0.00")).toEqual({ + specifier: ",.2f", + prefix: "$", + }); + expect(toD3Format("#,##0")).toEqual({ specifier: ",.0f" }); + expect(toD3Format("#,##0.00")).toEqual({ specifier: ",.2f" }); + expect(toD3Format("0.0%")).toEqual({ specifier: ".1%" }); + }); + + test("no spec returns undefined", () => { + expect(toD3Format()).toBeUndefined(); + expect(toD3Format("")).toBeUndefined(); + }); + + test("unrecognized specs return undefined", () => { + expect(toD3Format("yyyy-MM-dd")).toBeUndefined(); + expect(toD3Format("abc")).toBeUndefined(); + }); + + // Currency stays separate from the d3 specifier because d3's `$` marker is + // locale-driven and cannot encode arbitrary symbols. Consumers such as + // Plotly can pass these through as `tickformat` + `tickprefix`. + test.each([ + ["$#,##0.00", ",.2f", "$"], + ["€#,##0.00", ",.2f", "€"], + ["£#,##0.00", ",.2f", "£"], + ["¥#,##0", ",.0f", "¥"], + ["₹#,##0.00", ",.2f", "₹"], + ["R$#,##0.00", ",.2f", "R$"], + ["XYZ #,##0.00", ",.2f", "XYZ "], + ])( + "maps currency spec %s without replacing its prefix", + (spec, specifier, prefix) => { + expect(toD3Format(spec)).toEqual({ specifier, prefix }); + }, + ); +}); + +describe("js/format spec caching", () => { + test("repeated formatting with same spec produces identical string", () => { + const spec = "$#,##0.00"; + const value = 1234.5; + const result1 = formatValue(value, spec); + const result2 = formatValue(value, spec); + expect(result1).toBe(result2); + expect(result1).toBe("$1,234.50"); + }); + + test("different specs applied to same value still produce correct outputs", () => { + const value = 1234.5; + const result1 = formatValue(value, "$#,##0.00"); + const result2 = formatValue(value, "€#,##0"); + const result3 = formatValue(value, "#,##0.00"); + expect(result1).toBe("$1,234.50"); + expect(result2).toBe("€1,235"); + expect(result3).toBe("1,234.50"); + }); + + test("cache does not corrupt currency prefix on repeated specs", () => { + const value = 5000; + // Format with USD, then EUR, then USD again to verify cache hit doesn't + // bleed currency prefix across specs. + expect(formatValue(value, "$#,##0.00")).toBe("$5,000.00"); + expect(formatValue(value, "€#,##0")).toBe("€5,000"); + expect(formatValue(value, "$#,##0.00")).toBe("$5,000.00"); + }); + + test("cache does not corrupt decimal places on repeated specs", () => { + const value = 1234.5678; + // Format with 2 decimals, then 0, then 2 again to verify cache hit + // doesn't bleed decimal count across specs. + expect(formatValue(value, "#,##0.00")).toBe("1,234.57"); + expect(formatValue(value, "#,##0")).toBe("1,235"); + expect(formatValue(value, "#,##0.00")).toBe("1,234.57"); + }); + + test("toD3Format reuses same cache for repeated specs", () => { + const spec = "$#,##0.00"; + const result1 = toD3Format(spec); + const result2 = toD3Format(spec); + expect(result1).toEqual({ specifier: ",.2f", prefix: "$" }); + expect(result2).toEqual({ specifier: ",.2f", prefix: "$" }); + }); + + test("cache preserves precision for large integer strings on repeated specs", () => { + const spec = "#,##0"; + const value = "9007199254740993"; + const result1 = formatValue(value, spec); + const result2 = formatValue(value, spec); + expect(result1).toBe("9,007,199,254,740,993"); + expect(result2).toBe("9,007,199,254,740,993"); + }); + + test("cache preserves currency on large values", () => { + const spec = "$#,##0.00"; + const value = "12345678901234567.89"; + const result1 = formatValue(value, spec); + const result2 = formatValue(value, spec); + expect(result1).toBe("$12,345,678,901,234,567.89"); + expect(result2).toBe("$12,345,678,901,234,567.89"); + }); +}); diff --git a/packages/appkit-ui/src/js/format/index.ts b/packages/appkit-ui/src/js/format/index.ts new file mode 100644 index 000000000..454eaa828 --- /dev/null +++ b/packages/appkit-ui/src/js/format/index.ts @@ -0,0 +1,283 @@ +import type { MetricViewColumnDisplay } from "shared"; + +export type { MetricViewColumnDisplay }; + +const SPEC_CACHE_MAX = 256; +const specCache = new Map< + string, + { + isPercent: boolean; + grouping: boolean; + decimals: number; + prefix: string; + } +>(); + +function countDecimals(format: string): number { + const dotIndex = format.indexOf("."); + if (dotIndex === -1) return 0; + const frac = format.slice(dotIndex + 1); + const match = frac.match(/^[0#]+/); + return match ? match[0].length : 0; +} + +/** + * Best-effort coercion of an arbitrary value to a finite number. + * Returns null when the value cannot be meaningfully treated as a number. + */ +function coerceNumber(value: unknown): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") { + if (value.trim() === "") return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +interface ExactDecimal { + coefficient: bigint; + negative: boolean; + scale: number; +} + +/** Parse a plain decimal string without passing through a JS number. */ +function parseExactDecimal(value: string): ExactDecimal | null { + const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(value.trim()); + if (!match) return null; + + const fraction = match[3] ?? ""; + const coefficient = BigInt(`${match[2]}${fraction}`); + return { + coefficient, + negative: match[1] === "-" && coefficient !== 0n, + scale: fraction.length, + }; +} + +function powerOfTen(exponent: number): bigint { + return 10n ** BigInt(exponent); +} + +/** Round an unsigned fixed-point coefficient to the requested display scale. */ +function quantizeDecimal( + coefficient: bigint, + sourceScale: number, + targetScale: number, +): bigint { + if (sourceScale <= targetScale) { + return coefficient * powerOfTen(targetScale - sourceScale); + } + + const divisor = powerOfTen(sourceScale - targetScale); + const quotient = coefficient / divisor; + const remainder = coefficient % divisor; + return remainder * 2n >= divisor ? quotient + 1n : quotient; +} + +function groupInteger(value: string): string { + return value.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +/** + * Format an exact fixed-point value without ever coercing it to `Number`. + * Rounding is half-away-from-zero, matching Intl.NumberFormat's default. + */ +function formatExactDecimal( + value: ExactDecimal, + decimals: number, + grouping: boolean, + prefix: string, + percent: boolean, +): string { + const coefficient = percent ? value.coefficient * 100n : value.coefficient; + const quantized = quantizeDecimal(coefficient, value.scale, decimals); + const digits = quantized.toString().padStart(decimals + 1, "0"); + const split = digits.length - decimals; + const integer = grouping + ? groupInteger(digits.slice(0, split)) + : digits.slice(0, split); + const fraction = decimals > 0 ? `.${digits.slice(split)}` : ""; + const sign = value.negative ? "-" : ""; + return `${sign}${prefix}${integer}${fraction}${percent ? "%" : ""}`; +} + +/** Format a number with fixed decimals + optional thousands grouping. */ +function formatNumber( + value: number, + decimals: number, + grouping: boolean, +): string { + return value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: grouping, + }); +} + +/** + * The currency symbol a spec carries — everything before the first digit + * placeholder (`#`/`0`). The metric-view generator emits `$`, `€`, `£`, `¥`, + * `₹`, `R$`, or an unknown ISO code + space (e.g. `"XYZ "`); this recovers any + * of them verbatim. Returns `""` for a bare numeric spec (`"#,##0"`) or a + * percent spec (`"0.0%"`), neither of which has a leading symbol. + */ +function currencyPrefix(format: string): string { + const match = format.match(/^[^#0]+/); + return match ? match[0] : ""; +} + +/** The spec's `{ isPercent, grouping, decimals, prefix }`, parsed at most once. */ +function getOrParseSpec(format: string) { + const cached = specCache.get(format); + if (cached !== undefined) return cached; + + // Cache miss: parse now and store if under the limit. + const parsed = { + isPercent: format.includes("%"), + grouping: format.includes(","), + decimals: countDecimals(format), + prefix: currencyPrefix(format), + }; + + if (specCache.size < SPEC_CACHE_MAX) { + specCache.set(format, parsed); + } + + return parsed; +} + +/** + * Format a raw value using a UC/YAML printf-style format spec. + * + * + * No format spec -> sensible default: numbers via `toLocaleString`, everything + * else via `String()`. `null`/`undefined` -> `""`. Unrecognized specs fall back + * to a best-effort result (the number grouped, or `String(value)`). + */ +export function formatValue(value: unknown, format?: string): string { + if (value === null || value === undefined) return ""; + + if (!format) { + if (typeof value === "number") { + return Number.isFinite(value) ? value.toLocaleString() : String(value); + } + if (typeof value === "bigint") return value.toLocaleString(); + return String(value); + } + + const { isPercent, grouping, decimals, prefix } = getOrParseSpec(format); + + // JSON_ARRAY delivers SQL scalar cells as strings. Parse plain integer and + // fractional forms as fixed-point values so DECIMAL precision is never lost + // through Number(), even when the magnitude exceeds ±2^53. + const exact = + typeof value === "bigint" + ? { + coefficient: value < 0n ? -value : value, + negative: value < 0n, + scale: 0, + } + : typeof value === "string" + ? parseExactDecimal(value) + : null; + if (exact !== null) { + return formatExactDecimal( + exact, + decimals, + grouping, + isPercent ? "" : prefix, + isPercent, + ); + } + + const num = coerceNumber(value); + // Non-numeric value with a numeric-ish spec: nothing sensible to format. + if (num === null) return String(value); + + if (isPercent) { + return `${formatNumber(num * 100, decimals, grouping)}%`; + } + + if (prefix) { + const sign = num < 0 ? "-" : ""; + return `${sign}${prefix}${formatNumber(Math.abs(num), decimals, grouping)}`; + } + + return formatNumber(num, decimals, grouping); +} + +// Turns a raw column name into a human-readable label. +function humanize(name: string): string { + return ( + name + // Handle consecutive uppercase followed by lowercase (e.g., HTTPUrl -> HTTP Url) + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + // Handle lowercase followed by uppercase (e.g., totalSpend -> total Spend) + .replace(/([a-z])([A-Z])/g, "$1 $2") + // Replace underscores with spaces + .replace(/_/g, " ") + // Collapse multiple spaces into one + .replace(/\s+/g, " ") + // Normalize to title case + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase()) + .trim() + ); +} + +export function formatLabel( + name: string, + columnMeta?: MetricViewColumnDisplay, +): string { + if (columnMeta?.display_name) return columnMeta.display_name; + return humanize(name); +} + +export interface D3FormatParts { + /** Numeric d3-format specifier, without a currency symbol. */ + specifier: string; + /** Literal currency prefix from the UC format, when present. */ + prefix?: string; +} + +/** + * Maps a UC/spreadsheet-style format spec to the pieces consumed by + * [d3-format](https://d3js.org/d3-format)-based charts. + * + * Best-effort mapping for the common specs: + * - `"$#,##0.00"` -> `{ specifier: ",.2f", prefix: "$" }` + * - `"€#,##0.00"` -> `{ specifier: ",.2f", prefix: "€" }` + * - `"#,##0"` -> `{ specifier: ",.0f" }`, `"0.0%"` -> `{ specifier: ".1%" }` + * + * A d3 specifier cannot encode an arbitrary currency symbol: its `$` marker is + * resolved through global locale configuration. Returning the literal prefix + * separately lets consumers such as Plotly pass it as `tickprefix` instead of + * silently rendering every currency as `$`. + */ +export function toD3Format(format?: string): D3FormatParts | undefined { + if (!format) return undefined; + + // Strip any leading currency prefix first, then require the remainder to be + // built purely from numeric-format characters; anything else (date patterns, + // free text, ...) is left unrecognized. + const { prefix, grouping, decimals, isPercent } = getOrParseSpec(format); + const numeric = format.slice(prefix.length); + if (numeric.replace(/[#0,.%\s]/g, "") !== "") return undefined; + if (!/[0#]/.test(numeric)) return undefined; + + const group = grouping ? "," : ""; + + if (isPercent) { + return { + specifier: `${group}.${decimals}%`, + ...(prefix ? { prefix } : {}), + }; + } + + return { + specifier: `${group}.${decimals}f`, + ...(prefix ? { prefix } : {}), + }; +} diff --git a/packages/appkit-ui/src/js/index.ts b/packages/appkit-ui/src/js/index.ts index f49cde96e..86447be01 100644 --- a/packages/appkit-ui/src/js/index.ts +++ b/packages/appkit-ui/src/js/index.ts @@ -12,4 +12,6 @@ export { export * from "./arrow"; export * from "./config"; export * from "./constants"; +export * from "./format"; +export * from "./metric-filter"; export * from "./sse"; diff --git a/packages/appkit-ui/src/js/metric-filter/index.test.ts b/packages/appkit-ui/src/js/metric-filter/index.test.ts new file mode 100644 index 000000000..767c680b7 --- /dev/null +++ b/packages/appkit-ui/src/js/metric-filter/index.test.ts @@ -0,0 +1,123 @@ +import type { + MetricFilter as SharedMetricFilter, + MetricFilterOperatorName as SharedMetricFilterOperatorName, + MetricOrderBy as SharedMetricOrderBy, + MetricOrderDirection as SharedMetricOrderDirection, + MetricPredicate as SharedMetricPredicate, +} from "shared"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { + type MetricFilter, + type MetricFilterOperatorName, + type MetricOrderBy, + type MetricOrderDirection, + type MetricPredicate, + toMetricFilter, +} from "./index"; + +describe("toMetricFilter", () => { + test("re-exports the shared metric-filter AST types", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("returns undefined for an empty selection", () => { + expect(toMetricFilter({})).toBeUndefined(); + }); + + test("omits members with undefined values", () => { + expect(toMetricFilter({ region: undefined })).toBeUndefined(); + expect(toMetricFilter({ region: undefined, segment: "SMB" })).toEqual({ + member: "segment", + operator: "equals", + values: ["SMB"], + }); + }); + + test("omits members with empty-array values", () => { + expect(toMetricFilter({ region: [] })).toBeUndefined(); + }); + + test("compiles a null value to notSet (IS NULL), not equals 'null'", () => { + expect(toMetricFilter({ region: null })).toEqual({ + member: "region", + operator: "notSet", + }); + }); + + test("distinguishes null (IS NULL) from undefined (no filter)", () => { + expect(toMetricFilter({ region: null, segment: undefined })).toEqual({ + member: "region", + operator: "notSet", + }); + }); + + test("omits `values` entirely for a null member", () => { + // `notSet` rejects `values` server-side, so the key must be absent rather + // than present-and-empty. + expect(toMetricFilter({ region: null })).not.toHaveProperty("values"); + }); + + test("combines a null member with a scalar member under and", () => { + expect(toMetricFilter({ region: null, segment: "SMB" })).toEqual({ + and: [ + { member: "region", operator: "notSet" }, + { member: "segment", operator: "equals", values: ["SMB"] }, + ], + }); + }); + + test("compiles a single scalar member to a bare equals predicate", () => { + expect(toMetricFilter({ region: "EMEA" })).toEqual({ + member: "region", + operator: "equals", + values: ["EMEA"], + }); + }); + + test("compiles a numeric scalar to an equals predicate", () => { + expect(toMetricFilter({ tier: 2 })).toEqual({ + member: "tier", + operator: "equals", + values: [2], + }); + }); + + test("compiles an array member to an in predicate", () => { + expect(toMetricFilter({ region: ["EMEA", "APAC"] })).toEqual({ + member: "region", + operator: "in", + values: ["EMEA", "APAC"], + }); + }); + + test("AND-groups multiple members, mixing equals and in", () => { + expect( + toMetricFilter({ region: ["EMEA", "APAC"], segment: "SMB" }), + ).toEqual({ + and: [ + { member: "region", operator: "in", values: ["EMEA", "APAC"] }, + { member: "segment", operator: "equals", values: ["SMB"] }, + ], + }); + }); + + test("copies array values rather than aliasing the caller's array", () => { + const values = ["EMEA", "APAC"]; + const filter = toMetricFilter({ region: values }); + // A single member compiles to a bare predicate (has `values`), not a group. + if (!filter || !("values" in filter)) { + throw new Error("expected a leaf predicate with values"); + } + expect(filter.values).toEqual(values); + expect(filter.values).not.toBe(values); + }); + + test("produces a MetricFilter assignable to the exported type", () => { + const filter: MetricFilter | undefined = toMetricFilter({ region: "EMEA" }); + expect(filter).toBeDefined(); + }); +}); diff --git a/packages/appkit-ui/src/js/metric-filter/index.ts b/packages/appkit-ui/src/js/metric-filter/index.ts new file mode 100644 index 000000000..1711ad3f7 --- /dev/null +++ b/packages/appkit-ui/src/js/metric-filter/index.ts @@ -0,0 +1,70 @@ +import type { MetricFilter, MetricPredicate } from "shared"; + +export type { + MetricFilter, + MetricFilterOperatorName, + MetricOrderBy, + MetricOrderDirection, + MetricPredicate, +} from "shared"; + +/** + * Shorthand map of `dimension -> selected value(s)`. + * A member is dropped when its value is `undefined` or empty array, + * so a partially-filled filter-bar selection maps straight to "no predicate for that dimension". + * + */ +export type MetricFilterShorthand = Record< + string, + string | number | null | ReadonlyArray | undefined +>; + +/** + * Compile a `{ dimension -> value(s) }` shorthand into a {@link MetricFilter} + * Returns a bare {@link MetricPredicate} for a single member, an `and` group for + * several, and `undefined` when nothing is selected (so the caller can pass it + * straight to `useMetricView`'s optional `filter`, which omits the field when + * `undefined`). For operators beyond equality/membership (ranges, `contains`, + * `set`), build the {@link MetricFilter} tree directly. + * + * @example + * ```typescript + * toMetricFilter({ region: "EMEA" }); + * // → { member: "region", operator: "equals", values: ["EMEA"] } + * + * toMetricFilter({ region: ["EMEA", "APAC"], segment: "SMB" }); + * // → { and: [ + * // { member: "region", operator: "in", values: ["EMEA", "APAC"] }, + * // { member: "segment", operator: "equals", values: ["SMB"] }, + * // ] } + * + * toMetricFilter({ region: undefined }); // → undefined + * + * toMetricFilter({ region: null }); + * // → { member: "region", operator: "notSet" } + * ``` + */ +export function toMetricFilter( + selection: MetricFilterShorthand, +): MetricFilter | undefined { + const predicates: MetricPredicate[] = []; + for (const member of Object.keys(selection)) { + const value = selection[member]; + if (value === undefined) continue; + if (value === null) { + predicates.push({ member, operator: "notSet" }); + } else if (Array.isArray(value)) { + if (value.length === 0) continue; + predicates.push({ member, operator: "in", values: [...value] }); + } else { + predicates.push({ + member, + operator: "equals", + values: [value as string | number], + }); + } + } + if (predicates.length === 0) return undefined; + if (predicates.length === 1) return predicates[0]; + return { and: predicates }; +} diff --git a/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx b/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx index 140502f43..77978d3cb 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx +++ b/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx @@ -10,6 +10,7 @@ * producing blank charts. */ import { cleanup, render, waitFor } from "@testing-library/react"; +import * as echarts from "echarts/core"; import { afterEach, beforeAll, @@ -223,6 +224,65 @@ describe("BaseChart ECharts registration", () => { expect(registrationErrors).toEqual([]); }); + test("passes the chart instance when normalizing a line stroke click", async () => { + const onDataClick = vi.fn(); + const { container } = render( + , + ); + + const chartElement = await waitFor(() => { + const element = + container.querySelector(".echarts-for-react"); + expect(element).not.toBeNull(); + return element as HTMLElement; + }); + type TestChartInstance = { + convertToPixel( + finder: { seriesIndex: number }, + value: (string | number)[], + ): unknown; + isSilent(eventName: string): boolean; + trigger(eventName: string, params: unknown): void; + }; + const instance = await waitFor(() => { + const current = echarts.getInstanceByDom(chartElement) as + | (ReturnType & TestChartInstance) + | undefined; + expect(current).toBeDefined(); + expect(current?.isSilent("click")).toBe(false); + return current as TestChartInstance; + }); + + instance.convertToPixel = vi.fn((_finder, point) => [ + cartesianData.findIndex((row) => row.month === point[0]) * 100, + point[1], + ]); + + instance.trigger("click", { + seriesType: "line", + seriesName: "Revenue", + seriesIndex: 0, + event: { offsetX: 185, offsetY: 90 }, + }); + + expect(onDataClick).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Mar", + value: 90, + x: "Mar", + y: 90, + dataIndex: 2, + seriesIndex: 0, + }), + ); + }); + test("renders the no-data fallback for empty data without mounting ECharts", () => { const { container, getByText } = render( , diff --git a/packages/appkit-ui/src/react/charts/__tests__/normalize.test.ts b/packages/appkit-ui/src/react/charts/__tests__/normalize.test.ts index 168c345de..fac3909dd 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/normalize.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/normalize.test.ts @@ -142,16 +142,13 @@ describe("normalizeChartData", () => { }); test("sorts time-series data in ascending order", () => { - // The current implementation uses sortTimeSeriesAscending which only - // sorts if first > last (fully reversed), not partially unsorted data - // So we test with fully reversed data - const reversedData = [ + const shuffledData = [ + { date: "2025-01-01", value: 100 }, { date: "2025-01-03", value: 300 }, { date: "2025-01-02", value: 200 }, - { date: "2025-01-01", value: 100 }, ]; - const result = normalizeChartData(reversedData); + const result = normalizeChartData(shuffledData); // First timestamp should be earliest after sorting expect(result.xData[0]).toBeLessThan(result.xData[1] as number); @@ -166,6 +163,39 @@ describe("normalizeChartData", () => { const expectedTimestamp = new Date("2025-01-15T12:00:00Z").getTime(); expect(result.xData[0]).toBe(expectedTimestamp); }); + + // Spark JSON_ARRAY separates date and time with a space rather than "T". + // Detection and sorting must agree on that form, or the rows reach the + // chart in whatever order SQL returned them. + test("sorts space-separated SQL timestamps", () => { + const shuffledData = [ + { created_at: "2025-01-01 00:00:00", arr: 100 }, + { created_at: "2025-03-01 00:00:00", arr: 300 }, + { created_at: "2025-02-01 00:00:00", arr: 200 }, + ]; + + const result = normalizeChartData(shuffledData); + + expect(result.chartType).toBe("timeseries"); + expect(result.yDataMap.arr).toEqual([100, 200, 300]); + expect(result.xData[0]).toBeLessThan(result.xData[1] as number); + expect(result.xData[1]).toBeLessThan(result.xData[2] as number); + }); + + test("treats a date-shaped but unparseable value as categorical", () => { + const data = [ + { created_at: "2025-01-01 not-a-time", arr: 100 }, + { created_at: "2025-03-01 also-not", arr: 300 }, + ]; + + const result = normalizeChartData(data); + + expect(result.chartType).toBe("categorical"); + expect(result.xData).toEqual([ + "2025-01-01 not-a-time", + "2025-03-01 also-not", + ]); + }); }); describe("edge cases", () => { diff --git a/packages/appkit-ui/src/react/charts/__tests__/options.test.ts b/packages/appkit-ui/src/react/charts/__tests__/options.test.ts index 5a777fafd..c37de285f 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/options.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/options.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { FALLBACK_UI_TOKENS } from "../constants"; import { + applySelectionEmphasis, buildCartesianOption, buildHeatmapOption, buildHorizontalBarOption, @@ -19,8 +20,22 @@ interface EChartsOption { borderColor?: string; textStyle?: { color: string }; }; - xAxis: { type: string; data?: unknown[] }; - yAxis: { type: string; data?: unknown[] }; + xAxis: { + type: string; + data?: unknown[]; + axisLabel?: { + color?: string; + formatter?: (value: string | number) => string; + }; + }; + yAxis: { + type: string; + data?: unknown[]; + axisLabel?: { + color?: string; + formatter?: (value: string | number) => string; + }; + }; series: Array<{ type: string; data: unknown[]; @@ -28,12 +43,19 @@ interface EChartsOption { showSymbol?: boolean; symbol?: string; symbolSize?: number; + triggerLineEvent?: boolean; areaStyle?: { opacity: number }; stack?: string; itemStyle?: { borderRadius?: number[] }; color?: string; - label?: { show: boolean; position: string }; + label?: { + show: boolean; + position: string; + formatter?: (params: { data: [number, number, number] }) => string; + }; + tooltip?: { valueFormatter?: (value: unknown) => string }; radius?: string | string[]; + minAngle?: number; }>; radar?: { indicator: Array<{ name: string; max: number }>; @@ -41,6 +63,7 @@ interface EChartsOption { visualMap?: { min: number; max: number; + formatter?: (value: number) => string; inRange: { color: string[] }; }; } @@ -48,7 +71,11 @@ interface EChartsOption { interface RadarOption { series: Array<{ type: string; - data: Array<{ value: number[]; areaStyle?: { opacity: number } }>; + data: Array<{ + value: number[]; + areaStyle?: { opacity: number }; + tooltip?: { valueFormatter: (value: unknown) => string }; + }>; }>; } @@ -106,6 +133,31 @@ describe("buildCartesianOption", () => { expect(opt.yAxis.type).toBe("value"); }); + test("formats value-axis ticks and series tooltips without replacing defaults", () => { + const valueFormatter = vi.fn( + (value: string | number, field: string) => `${field}: ${value}`, + ); + const ctx = createBaseContext({ valueFormatter }); + const opt = asOption( + buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }), + ); + + expect(opt.yAxis.axisLabel?.formatter?.(1234)).toBe("value: 1234"); + expect(opt.series[0].tooltip?.valueFormatter?.(["Jan", 1234])).toBe( + "value: 1234", + ); + expect(opt.tooltip?.backgroundColor).toBe(TEST_UI.tooltipBg); + expect(opt.yAxis.axisLabel).toMatchObject({ color: TEST_UI.axisLabel }); + }); + test("applies border radius to bars", () => { const ctx = createBaseContext(); const opt = asOption( @@ -200,6 +252,65 @@ describe("buildCartesianOption", () => { expect(opt.series[0].smooth).toBe(false); expect(opt.series[0].showSymbol).toBe(false); }); + + test("applies symbolSize to line series (not just scatter)", () => { + const ctx = createBaseContext(); + const opt = asOption( + buildCartesianOption({ + ...ctx, + chartType: "line", + isTimeSeries: false, + stacked: false, + smooth: true, + showSymbol: true, + symbolSize: 14, + }), + ); + + expect(opt.series[0].symbolSize).toBe(14); + }); + + test("sets triggerLineEvent only when interactive", () => { + const ctx = createBaseContext(); + const base = { + ...ctx, + chartType: "line" as const, + isTimeSeries: false, + stacked: false, + smooth: true, + showSymbol: true, + symbolSize: 8, + }; + + // Non-interactive line: no triggerLineEvent. + expect( + asOption(buildCartesianOption(base)).series[0].triggerLineEvent, + ).toBeUndefined(); + + // Interactive line: whole stroke is clickable. + expect( + asOption(buildCartesianOption({ ...base, interactive: true })).series[0] + .triggerLineEvent, + ).toBe(true); + }); + + test("does not set triggerLineEvent on a bar series even when interactive", () => { + const ctx = createBaseContext(); + const opt = asOption( + buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + interactive: true, + }), + ); + + expect(opt.series[0].triggerLineEvent).toBeUndefined(); + }); }); describe("area chart", () => { @@ -480,6 +591,16 @@ describe("buildHorizontalBarOption", () => { expect(opt.legend).toBeDefined(); }); + + test("formats the horizontal value axis", () => { + const ctx = createBaseContext({ + valueFormatter: (value, field) => `${field}: ${value}`, + }); + const opt = asOption(buildHorizontalBarOption(ctx, false)); + + expect(opt.xAxis.axisLabel?.formatter?.(42)).toBe("value: 42"); + expect(opt.series[0].tooltip?.valueFormatter?.(42)).toBe("value: 42"); + }); }); describe("buildPieOption", () => { @@ -512,6 +633,16 @@ describe("buildPieOption", () => { expect(opt.series[0].radius).toEqual(["50%", "70%"]); }); + test("keeps tiny pie and donut slices pointer-selectable", () => { + const ctx = createBaseContext({ + yDataMap: { value: [99.6, 0.4] }, + xData: ["Large", "Tiny"], + }); + const opt = asOption(buildPieOption(ctx, "donut", 50, true, "outside")); + + expect(opt.series[0].minAngle).toBe(3); + }); + test("uses default inner radius for donut type", () => { const ctx = createBaseContext(); const opt = asOption(buildPieOption(ctx, "donut", 0, true, "inside")); @@ -547,6 +678,23 @@ describe("buildPieOption", () => { const center = asOption(buildPieOption(ctx, "pie", 0, true, "center")); expect(center.series[0].label?.position).toBe("center"); }); + + test("formats tooltip values and preserves tooltip theming", () => { + const ctx = createBaseContext({ + valueFormatter: (value, field) => `${field}: $${value}`, + }); + const opt = asOption(buildPieOption(ctx, "pie", 0, true, "outside")); + const formatter = opt.tooltip?.formatter as unknown as (params: { + name: string; + value: number; + percent: number; + }) => string; + + expect(formatter({ name: "A", value: 10, percent: 25 })).toBe( + "A: value: $10 (25%)", + ); + expect(opt.tooltip?.backgroundColor).toBe(TEST_UI.tooltipBg); + }); }); describe("buildRadarOption", () => { @@ -591,13 +739,22 @@ describe("buildRadarOption", () => { expect(opt.series[0].type).toBe("radar"); expect(opt.series[0].data[0].value).toEqual([10, 20, 30]); }); + + test("formats radar tooltip values with the matching series field", () => { + const ctx = createBaseContext({ + valueFormatter: (value, field) => `${field}: ${value}`, + }); + const opt = asRadarOption(buildRadarOption(ctx, true)); + + expect(opt.series[0].data[0].tooltip?.valueFormatter(10)).toBe("value: 10"); + }); }); describe("buildHeatmapOption", () => { const createHeatmapContext = (): HeatmapContext => ({ xData: ["9AM", "10AM", "11AM"], yDataMap: {}, - yFields: [], + yFields: ["value"], colors: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"], title: "Activity Heatmap", showLegend: false, @@ -687,6 +844,24 @@ describe("buildHeatmapOption", () => { "<img src=x onerror=alert(1)>, <script>alert(2)</script>: 10", ); }); + + test("formats tooltips, labels, and the visual scale", () => { + const ctx = { + ...createHeatmapContext(), + showLabels: true, + valueFormatter: (value: string | number, field: string) => + `${field}: ${value}%`, + }; + const opt = asOption(buildHeatmapOption(ctx)); + + expect(opt.tooltip?.formatter?.({ data: [1, 2, 25] })).toBe( + "10AM, Wed: value: 25%", + ); + expect(opt.series[0].label?.formatter?.({ data: [1, 2, 25] })).toBe( + "value: 25%", + ); + expect(opt.visualMap?.formatter?.(25)).toBe("value: 25%"); + }); }); describe("axis & UI theming", () => { @@ -928,3 +1103,252 @@ describe("tooltip theming", () => { expect(opt.tooltip?.formatter?.({ data: [0, 0, 10] })).toBe("A, Mon: 10"); }); }); + +// ============================================================================ +// applySelectionEmphasis — the cross-filter highlight transform +// ============================================================================ + +describe("applySelectionEmphasis", () => { + // Minimal helpers to read opacity off a transformed datum, tolerating both the + // object form ({ value, itemStyle }) and the wrapped-primitive form. + const opacityOf = (datum: unknown): number | undefined => + (datum as { itemStyle?: { opacity?: number } })?.itemStyle?.opacity; + + const barOption = (categories: (string | number)[], values: number[]) => ({ + xAxis: { type: "category", data: categories }, + yAxis: { type: "value" }, + series: [{ type: "bar", data: values }], + }); + + describe("no-op cases (identity)", () => { + test("undefined selection returns the input unchanged (same reference)", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + expect(applySelectionEmphasis(opt, undefined)).toBe(opt); + }); + + test("empty-string selection is a no-op — does NOT dim everything", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + // The bug being guarded: "" would match no category and dim all bars. + expect(applySelectionEmphasis(opt, "")).toBe(opt); + }); + + test("empty-array selection is a no-op", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + expect(applySelectionEmphasis(opt, [])).toBe(opt); + }); + + test("an array of only empty strings is a no-op", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + expect(applySelectionEmphasis(opt, ["", ""])).toBe(opt); + }); + + test("option without a series array is returned unchanged", () => { + const opt = { xAxis: { type: "category", data: ["A"] } }; + expect(applySelectionEmphasis(opt, "A")).toBe(opt); + }); + }); + + describe("bar series (category axis)", () => { + test("dims non-selected categories and keeps the selected one at full opacity", () => { + const opt = barOption(["EMEA", "APAC", "AMER"], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, "APAC")); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); // EMEA dimmed + expect(opacityOf(data[1])).toBe(1); // APAC selected + expect(opacityOf(data[2])).toBe(0.3); // AMER dimmed + }); + + test("a mixed array selection ignores the dead empty-string member", () => { + const opt = barOption(["EMEA", "APAC", "AMER"], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, ["EMEA", "", "AMER"])); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(1); // EMEA selected + expect(opacityOf(data[1])).toBe(0.3); // APAC dimmed + expect(opacityOf(data[2])).toBe(1); // AMER selected + }); + + test("preserves the series-level itemStyle (bar borderRadius) via a real builder", () => { + const ctx = createBaseContext({ + xData: ["EMEA", "APAC"], + yDataMap: { value: [10, 20] }, + }); + const built = buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }); + const out = asOption(applySelectionEmphasis(built, "EMEA")); + + // The per-datum itemStyle carries opacity but the bar's borderRadius is + // set at the series level and must survive (per-datum merges OVER series). + expect(opacityOf(out.series[0].data[0])).toBe(1); + expect(opacityOf(out.series[0].data[1])).toBe(0.3); + expect(out.series[0].itemStyle?.borderRadius).toEqual([4, 4, 0, 0]); + }); + + test("matches numeric category names by their string form", () => { + const opt = barOption([2024, 2025, 2026], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, "2025")); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); + expect(opacityOf(data[1])).toBe(1); + expect(opacityOf(data[2])).toBe(0.3); + }); + + test("respects custom opacity overrides", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + const out = asOption( + applySelectionEmphasis(opt, "EMEA", { + dimmedOpacity: 0.1, + selectedOpacity: 0.9, + }), + ); + expect(opacityOf(out.series[0].data[0])).toBe(0.9); + expect(opacityOf(out.series[0].data[1])).toBe(0.1); + }); + + test("horizontal bars read categories from the yAxis", () => { + const ctx = createBaseContext({ + xData: ["EMEA", "APAC", "AMER"], + yDataMap: { value: [10, 20, 30] }, + }); + const built = buildHorizontalBarOption(ctx, false); + const out = asOption(applySelectionEmphasis(built, "APAC")); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); + expect(opacityOf(data[1])).toBe(1); + expect(opacityOf(data[2])).toBe(0.3); + }); + }); + + describe("pie series (name-keyed data)", () => { + test("dims non-selected slices, reading the name off each datum", () => { + const ctx = createBaseContext({ + xData: ["EMEA", "APAC", "AMER"], + yDataMap: { value: [10, 20, 30] }, + yFields: ["value"], + }); + const built = buildPieOption(ctx, "pie", 0, true, "outside"); + const out = asOption(applySelectionEmphasis(built, "AMER")); + + const data = out.series[0].data as Array<{ + name: string; + itemStyle?: { opacity?: number }; + }>; + // Object data items are spread — name/value survive alongside opacity. + expect(data[0]).toMatchObject({ name: "EMEA" }); + expect(data[0].itemStyle?.opacity).toBe(0.3); + expect(data[2].itemStyle?.opacity).toBe(1); + }); + }); + + describe("non-categorical series are passed through untouched", () => { + test("line series (no category name per datum) is unchanged", () => { + const opt = { + xAxis: { type: "category", data: ["A", "B"] }, + yAxis: { type: "value" }, + series: [{ type: "line", data: [10, 20] }], + }; + const out = asOption(applySelectionEmphasis(opt, "A")); + // Line data is left as raw values (no itemStyle wrapping). + expect(out.series[0].data).toEqual([10, 20]); + }); + + test("scatter series is unchanged", () => { + const opt = { + xAxis: { type: "value" }, + yAxis: { type: "value" }, + series: [ + { + type: "scatter", + data: [ + [1, 2], + [3, 4], + ], + }, + ], + }; + const out = asOption(applySelectionEmphasis(opt, "anything")); + expect(out.series[0].data).toEqual([ + [1, 2], + [3, 4], + ]); + }); + + test("bar with no category axis (e.g. value/value) is left unchanged", () => { + const opt = { + xAxis: { type: "value" }, + yAxis: { type: "value" }, + series: [{ type: "bar", data: [10, 20] }], + }; + const out = asOption(applySelectionEmphasis(opt, "A")); + expect(out.series[0].data).toEqual([10, 20]); + }); + }); + + describe("sentinel-based category emphasis (chart click round-trip)", () => { + // When chart data is pre-normalized with sentinels (e.g., __empty__ for "", + // __none__ for NULL), toSelectionSet accepts these non-empty sentinels and + // can emphasize them. This tests that the appkit-ui layer supports the + // pattern without breaking the documented "empty string = no-op" guard. + + test("emphasizes a sentinel category name (__empty__ for an empty-string value)", () => { + // Chart data has been pre-normalized: a row with "" becomes __empty__ + const EMPTY = "__empty__"; + const opt = barOption(["EMEA", EMPTY, "AMER"], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, EMPTY)); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); // EMEA dimmed + expect(opacityOf(data[1])).toBe(1); // __empty__ selected + expect(opacityOf(data[2])).toBe(0.3); // AMER dimmed + }); + + test("emphasizes a sentinel category name (__none__ for a NULL value)", () => { + // Chart data has been pre-normalized: a row with NULL becomes __none__ + const NONE = "__none__"; + const opt = barOption(["EMEA", NONE, "AMER"], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, NONE)); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); // EMEA dimmed + expect(opacityOf(data[1])).toBe(1); // __none__ selected + expect(opacityOf(data[2])).toBe(0.3); // AMER dimmed + }); + + test("still guards against an actual empty-string category (no pre-normalization)", () => { + // If data were NOT pre-normalized and somehow had a literal "", the guard + // still prevents it from dimming everything. + const opt = barOption(["EMEA", "", "AMER"], [10, 20, 30]); + expect(applySelectionEmphasis(opt, "")).toBe(opt); + }); + + test("pie series with sentinel categories work the same way", () => { + const EMPTY = "__empty__"; + const NONE = "__none__"; + const ctx = createBaseContext({ + xData: ["EMEA", EMPTY, NONE], + yDataMap: { value: [10, 20, 30] }, + yFields: ["value"], + }); + const built = buildPieOption(ctx, "pie", 0, true, "outside"); + const out = asOption(applySelectionEmphasis(built, EMPTY)); + + const data = out.series[0].data as Array<{ + name: string; + itemStyle?: { opacity?: number }; + }>; + expect(data[0].itemStyle?.opacity).toBe(0.3); // EMEA dimmed + expect(data[1].itemStyle?.opacity).toBe(1); // __empty__ selected + expect(data[2].itemStyle?.opacity).toBe(0.3); // __none__ dimmed + }); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts b/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts index 728494111..fd85c38d4 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts @@ -3,6 +3,7 @@ import { createTimeSeriesData, escapeHtml, formatLabel, + mapToDatum, sortTimeSeriesAscending, toChartArray, toChartValue, @@ -283,18 +284,50 @@ describe("sortTimeSeriesAscending", () => { expect(result.yDataMap.val).toEqual([30, 10, 20]); }); - test("does not sort partially unsorted data where first <= last", () => { - // Documents current behavior: only sorts when first > last (fully reversed) - // Partially unsorted data like [1, 3, 2] is NOT sorted because first (1) <= last (2) + test("sorts partially shuffled timestamps where first <= last", () => { const xData = [1, 3, 2]; const yDataMap = { val: [10, 30, 20] }; const yFields = ["val"]; const result = sortTimeSeriesAscending(xData, yDataMap, yFields); - // Returns unsorted - this is the current behavior - expect(result.xData).toEqual([1, 3, 2]); - expect(result.yDataMap.val).toEqual([10, 30, 20]); + expect(result.xData).toEqual([1, 2, 3]); + expect(result.yDataMap.val).toEqual([10, 20, 30]); + }); + + test("sorts shuffled ISO date strings and keeps series correlated", () => { + const xData = ["2025-01-01", "2025-03-01", "2025-02-01"]; + const yDataMap = { + sales: [10, 30, 20], + profit: [1, 3, 2], + }; + + const result = sortTimeSeriesAscending(xData, yDataMap, [ + "sales", + "profit", + ]); + + expect(result.xData).toEqual(["2025-01-01", "2025-02-01", "2025-03-01"]); + expect(result.yDataMap.sales).toEqual([10, 20, 30]); + expect(result.yDataMap.profit).toEqual([1, 2, 3]); + }); + + test("sorts SQL timestamp strings returned by JSON_ARRAY", () => { + const xData = [ + "2025-02-01 12:00:00", + "2025-01-01 12:00:00", + "2025-03-01 12:00:00", + ]; + const yDataMap = { val: [20, 10, 30] }; + + const result = sortTimeSeriesAscending(xData, yDataMap, ["val"]); + + expect(result.xData).toEqual([ + "2025-01-01 12:00:00", + "2025-02-01 12:00:00", + "2025-03-01 12:00:00", + ]); + expect(result.yDataMap.val).toEqual([10, 20, 30]); }); }); @@ -334,3 +367,240 @@ describe("createTimeSeriesData", () => { ]); }); }); + +describe("mapToDatum", () => { + test("normalizes a scalar (bar/pie) click: name + value, no x/y", () => { + const d = mapToDatum({ + name: "EMEA", + value: 42, + seriesName: "ARR", + dataIndex: 1, + seriesIndex: 0, + }); + expect(d).toMatchObject({ + name: "EMEA", + value: 42, + seriesName: "ARR", + dataIndex: 1, + seriesIndex: 0, + }); + expect(d.x).toBeUndefined(); + expect(d.y).toBeUndefined(); + }); + + test("splits an [x, y] tuple point into x/y and surfaces y as value", () => { + // A time-series point: value is [epochMs, amount]. + const d = mapToDatum({ + value: [1704067200000, 8_100_000], + seriesName: "ARR", + seriesIndex: 0, + dataIndex: 3, + }); + expect(d.x).toBe(1704067200000); + expect(d.y).toBe(8_100_000); + expect(d.value).toBe(8_100_000); + // No explicit name → the x component's string form fills in. + expect(d.name).toBe("1704067200000"); + }); + + test("keeps an explicit name even for a tuple datum", () => { + const d = mapToDatum({ name: "Apr 2026", value: [1704067200000, 5] }); + expect(d.name).toBe("Apr 2026"); + expect(d.x).toBe(1704067200000); + expect(d.y).toBe(5); + }); + + test("missing name and non-tuple value falls back to empty string / null", () => { + const d = mapToDatum({ seriesIndex: 0 }); + expect(d.name).toBe(""); + expect(d.value).toBeNull(); + expect(d.dataIndex).toBe(-1); + expect(d.seriesIndex).toBe(0); + }); + + test("preserves the raw params untouched", () => { + const params = { name: "X", value: 1, extra: { deep: true } }; + expect(mapToDatum(params).raw).toBe(params); + }); + + // Heatmap data items are `[xIndex, yIndex, value]` INDEX triples, so the + // generic [x, y] tuple reading would report the y *index* as the cell value. + test("reads a heatmap triple's cell value, not an axis index", () => { + const d = mapToDatum( + { + seriesType: "heatmap", + value: [2, 1, 87], + dataIndex: 5, + seriesIndex: 0, + }, + { xLabels: ["Jan", "Feb", "Mar"], yLabels: ["EMEA", "APAC"] }, + ); + expect(d.value).toBe(87); + expect(d.x).toBe("Mar"); + expect(d.y).toBe("APAC"); + }); + + test("falls back to raw heatmap indices when axis labels are absent", () => { + const d = mapToDatum({ seriesType: "heatmap", value: [2, 1, 87] }); + expect(d.value).toBe(87); + expect(d.x).toBe(2); + expect(d.y).toBe(1); + }); + + test("uses an out-of-range heatmap index verbatim", () => { + const d = mapToDatum( + { seriesType: "heatmap", value: [9, 0, 3] }, + { xLabels: ["Jan"], yLabels: ["EMEA"] }, + ); + expect(d.x).toBe(9); + expect(d.y).toBe("EMEA"); + expect(d.value).toBe(3); + }); + + // A radar item holds one value per indicator; no single scalar is honest. + test("reports no scalar value for a radar multi-measure item", () => { + const d = mapToDatum({ + seriesType: "radar", + name: "ACME", + value: [10, 20, 30], + seriesIndex: 0, + }); + expect(d.name).toBe("ACME"); + expect(d.value).toBeNull(); + expect(d.x).toBeUndefined(); + expect(d.y).toBeUndefined(); + // The full vector stays reachable through `raw`. + expect((d.raw as { value: number[] }).value).toEqual([10, 20, 30]); + }); + + test("still splits [x, y] tuples for line/scatter series types", () => { + const d = mapToDatum({ seriesType: "line", value: [1704067200000, 5] }); + expect(d.x).toBe(1704067200000); + expect(d.y).toBe(5); + expect(d.value).toBe(5); + }); + + test("resolves a series-level line stroke click to the nearest point", () => { + const params = { + seriesType: "line", + seriesName: "ARR", + seriesIndex: 0, + event: { offsetX: 218, offsetY: 75 }, + }; + const instance = { + getOption: () => ({ + series: [ + { + data: [ + [1000, 10], + [2000, 20], + [3000, 30], + ], + }, + ], + }), + convertToPixel: ( + _finder: { seriesIndex: number }, + value: (string | number)[], + ) => [Number(value[0]) / 10, Number(value[1])], + }; + + const d = mapToDatum(params, {}, instance); + + expect(d).toMatchObject({ + name: "2000", + value: 20, + x: 2000, + y: 20, + seriesName: "ARR", + dataIndex: 1, + seriesIndex: 0, + }); + expect(d.raw).toBe(params); + }); + + test("resolves a categorical line stroke using axis labels", () => { + const labels = ["Jan", "Feb", "Mar"]; + const instance = { + getOption: () => ({ series: [{ data: [10, 20, 30] }] }), + convertToPixel: ( + _finder: { seriesIndex: number }, + value: (string | number)[], + ) => [labels.indexOf(String(value[0])) * 100, Number(value[1])], + }; + + const d = mapToDatum( + { + seriesType: "line", + seriesIndex: 0, + event: { offsetX: 185, offsetY: 25 }, + }, + { xLabels: labels }, + instance, + ); + + expect(d).toMatchObject({ + name: "Mar", + value: 30, + x: "Mar", + y: 30, + dataIndex: 2, + seriesIndex: 0, + }); + }); + + test("tolerates a non-object payload", () => { + const d = mapToDatum(null); + expect(d.name).toBe(""); + expect(d.value).toBeNull(); + expect(d.raw).toBeNull(); + }); + + describe("sentinel-based category clicks (round-trip)", () => { + // When chart data is pre-normalized with sentinels (e.g., __empty__ for "", + // __none__ for NULL), a click on that sentinel bar/pie slice emits the sentinel + // name, which can then be decoded back to the original value. These tests + // verify mapToDatum round-trips sentinel names correctly. + + test("preserves __empty__ sentinel in a bar click (pre-normalized empty string)", () => { + // A bar chart with pre-normalized data: "" became __empty__ + const EMPTY = "__empty__"; + const d = mapToDatum({ + name: EMPTY, + value: 42, + seriesName: "ARR", + dataIndex: 1, + seriesIndex: 0, + }); + expect(d.name).toBe(EMPTY); + expect(d.value).toBe(42); + }); + + test("preserves __none__ sentinel in a pie click (pre-normalized NULL)", () => { + // A pie chart with pre-normalized data: NULL became __none__ + const NONE = "__none__"; + const d = mapToDatum({ + name: NONE, + value: 20, + seriesName: "ARR", + dataIndex: 0, + seriesIndex: 0, + }); + expect(d.name).toBe(NONE); + expect(d.value).toBe(20); + }); + + test("distinguishes sentinels: __empty__ vs __none__ vs regular values", () => { + const EMPTY = "__empty__"; + const NONE = "__none__"; + + const empty = mapToDatum({ name: EMPTY, value: 100 }); + const none = mapToDatum({ name: NONE, value: 200 }); + const regular = mapToDatum({ name: "EMEA", value: 300 }); + + expect(empty.name).toBe(EMPTY); + expect(none.name).toBe(NONE); + expect(regular.name).toBe("EMEA"); + }); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 54c473114..6306d2375 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -29,6 +29,7 @@ import ReactEChartsCore from "echarts-for-react/esm/core"; import { useCallback, useMemo, useRef } from "react"; import { normalizeChartData, normalizeHeatmapData } from "./normalize"; import { + applySelectionEmphasis, buildCartesianOption, buildHeatmapOption, buildHorizontalBarOption, @@ -38,11 +39,15 @@ import { } from "./options"; import { useChartUITokens, useThemeColors } from "./theme"; import type { + ChartBaseProps, + ChartClickDatum, ChartColorPalette, ChartData, ChartType, + ChartValueFormatter, Orientation, } from "./types"; +import { mapToDatum } from "./utils"; // ============================================================================ // ECharts Registration (modular imports for tree-shaking) @@ -166,8 +171,22 @@ export interface BaseChartProps { * duplicate echarts copies won't share registrations). */ options?: Record; + /** Formats measure values. See {@link ChartBaseProps.valueFormatter}. */ + valueFormatter?: ChartValueFormatter; /** Additional CSS classes */ className?: string; + /** + * Fired when a data element (bar, slice, point) is clicked. Fire-and-forget: + * the return value is ignored (async handlers are fine — the chart never awaits). + * The handler receives a normalized {@link ChartClickDatum}. + * + * Pointer-only: charts render to , so this does not fire for keyboard + * users. Provide a keyboard-accessible equivalent (e.g. a table row action) for + * the same action. + */ + onDataClick?: (datum: ChartClickDatum) => void; + /** Controlled selection by category name. See {@link ChartBaseProps.selected}. */ + selected?: string | string[]; } // ============================================================================ @@ -201,7 +220,10 @@ export function BaseChart({ min, max, options: customOptions, + valueFormatter, className, + onDataClick, + selected, }: BaseChartProps) { // Determine the appropriate color palette based on chart type const resolvedPalette = colorPalette ?? getDefaultPalette(chartType); @@ -210,6 +232,15 @@ export function BaseChart({ const ui = useChartUITokens(); + // Handler presence enables line interaction and gates events. Tracking the + // boolean keeps inline handler identities from rebuilding chart options. + const interactive = !!onDataClick; + + // Keep the latest handler in a ref so `onEvents` can call the current + // `onDataClick` without listing it as a dependency (see `onEvents` below). + const onDataClickRef = useRef(onDataClick); + onDataClickRef.current = onDataClick; + // Store ECharts instance directly to avoid stale ref issues on unmount const echartsInstanceRef = useRef(null); @@ -270,6 +301,7 @@ export function BaseChart({ showLegend, xField, ui, + valueFormatter, }; const isPie = chartType === "pie" || chartType === "donut"; const isRadar = chartType === "radar"; @@ -326,11 +358,13 @@ export function BaseChart({ smooth, showSymbol, symbolSize, + interactive, }); } - // Merge custom options - return customOptions ? { ...opt, ...customOptions } : opt; + // Apply selection after custom options; empty selection is a no-op. + const merged = customOptions ? { ...opt, ...customOptions } : opt; + return applySelectionEmphasis(merged, selected); }, [ normalized, colors, @@ -350,8 +384,52 @@ export function BaseChart({ min, max, customOptions, + valueFormatter, + selected, + interactive, ]); + // Heatmap data uses axis indexes; preserve labels for click results instead + // of exposing those raw positions. + const axisLabels = useMemo( + () => ({ + xLabels: normalized.xData, + yLabels: + "yAxisData" in normalized + ? (normalized.yAxisData as (string | number)[]) + : undefined, + }), + [normalized], + ); + + // `onEvents` must not re-subscribe when the data changes (e.g. each SSE tick). + const axisLabelsRef = useRef(axisLabels); + axisLabelsRef.current = axisLabels; + + // Memoize by handler presence because echarts-for-react re-subscribes whenever + // `onEvents` changes, while callers commonly pass a new inline callback on + // every render. This also avoids churn during frequently changing data such + // as SSE ticks. + const onEvents = useMemo( + () => + interactive + ? { + click: (params: unknown, instance: ECharts) => { + const result = onDataClickRef.current?.( + mapToDatum(params, axisLabelsRef.current, instance), + ) as void | Promise; + if ( + result && + typeof (result as Promise).then === "function" + ) { + (result as Promise).catch(() => {}); + } + }, + } + : undefined, + [interactive], + ); + if (!option) { return (
@@ -370,6 +448,7 @@ export function BaseChart({ opts={{ renderer: "canvas" }} notMerge={false} lazyUpdate={true} + onEvents={onEvents} /> ); } diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts index f5e374e8d..69bf32f99 100644 --- a/packages/appkit-ui/src/react/charts/index.ts +++ b/packages/appkit-ui/src/react/charts/index.ts @@ -85,6 +85,7 @@ export { // ============================================================================ export { + applySelectionEmphasis, buildCartesianOption, buildHeatmapOption, buildHorizontalBarOption, @@ -108,10 +109,12 @@ export type { BarChartSpecificProps, // Base props ChartBaseProps, + ChartClickDatum, ChartColorPalette, ChartData, ChartType, ChartUITokens, + ChartValueFormatter, // Data formats DataFormat, DataProps, diff --git a/packages/appkit-ui/src/react/charts/normalize.ts b/packages/appkit-ui/src/react/charts/normalize.ts index 7fe3a4f79..731ce193b 100644 --- a/packages/appkit-ui/src/react/charts/normalize.ts +++ b/packages/appkit-ui/src/react/charts/normalize.ts @@ -12,6 +12,7 @@ import { sortNumericAscending, sortTimeSeriesAscending, toChartArray, + toChronologicalValue, } from "./utils"; // ============================================================================ @@ -19,11 +20,11 @@ import { // ============================================================================ /** - * Checks if a value looks like an ISO date string + * Checks if a value is a date string a time axis can plot. */ function isDateString(value: unknown): boolean { if (typeof value !== "string") return false; - return /^\d{4}-\d{2}-\d{2}(T|$)/.test(value); + return toChronologicalValue(value) !== null; } /** @@ -157,9 +158,9 @@ function jsonValueToChartValue( return Number(value); } if (typeof value === "string") { - if (isDateField && isDateString(value)) { - const timestamp = new Date(value).getTime(); - if (!Number.isNaN(timestamp)) { + if (isDateField) { + const timestamp = toChronologicalValue(value); + if (timestamp !== null) { return timestamp; } } diff --git a/packages/appkit-ui/src/react/charts/options.ts b/packages/appkit-ui/src/react/charts/options.ts index e50711c83..84b64e404 100644 --- a/packages/appkit-ui/src/react/charts/options.ts +++ b/packages/appkit-ui/src/react/charts/options.ts @@ -1,5 +1,5 @@ import { FALLBACK_UI_TOKENS } from "./constants"; -import type { ChartType, ChartUITokens } from "./types"; +import type { ChartType, ChartUITokens, ChartValueFormatter } from "./types"; import { createTimeSeriesData, escapeHtml, @@ -20,6 +20,7 @@ export interface OptionBuilderContext { showLegend: boolean; xField?: string; ui?: ChartUITokens; + valueFormatter?: ChartValueFormatter; } export interface CartesianContext extends OptionBuilderContext { @@ -29,6 +30,12 @@ export interface CartesianContext extends OptionBuilderContext { smooth: boolean; showSymbol: boolean; symbolSize: number; + /** + * Whether a click handler is attached. When true, line/area series set + * `triggerLineEvent` so a click anywhere on the stroke fires (not just on a + * symbol) — otherwise clicking a thin line is nearly impossible to land. + */ + interactive?: boolean; } // ============================================================================ @@ -81,6 +88,28 @@ function tooltipTokens(ui: ChartUITokens) { }; } +/** + * ECharts passes tuple-backed series (time-series, scatter, heatmap) to tooltip + * value formatters as the whole tuple. The measure is always the final entry. + */ +function toMeasureValue(value: unknown): string | number { + const scalar = Array.isArray(value) ? value[value.length - 1] : value; + if (typeof scalar === "string" || typeof scalar === "number") return scalar; + return scalar == null ? "" : String(scalar); +} + +function tooltipValueFormatter(formatter: ChartValueFormatter, field: string) { + return (value: unknown) => formatter(toMeasureValue(value), field); +} + +function valueAxisLabel(ctx: OptionBuilderContext) { + const field = ctx.yFields[0]; + const formatter = ctx.valueFormatter; + return formatter && field + ? { formatter: (value: string | number) => formatter(value, field) } + : {}; +} + // ============================================================================ // Radar Chart Option // ============================================================================ @@ -90,6 +119,7 @@ export function buildRadarOption( showArea = true, ): Record { const ui = ctx.ui ?? FALLBACK_UI_TOKENS; + const formatter = ctx.valueFormatter; const maxValue = Math.max( ...ctx.yFields.flatMap((f) => ctx.yDataMap[f].map((v) => Number(v) || 0)), ); @@ -117,6 +147,16 @@ export function buildRadarOption( data: ctx.yFields.map((key, idx) => ({ name: formatLabel(key), value: ctx.yDataMap[key], + tooltip: formatter + ? { + valueFormatter: (value: unknown) => { + const values = Array.isArray(value) ? value : [value]; + return values + .map((item) => formatter(toMeasureValue(item), key)) + .join(", "); + }, + } + : undefined, itemStyle: { color: ctx.colors[idx % ctx.colors.length] }, areaStyle: showArea ? { opacity: 0.3 } : undefined, })), @@ -143,13 +183,25 @@ export function buildPieOption( })); const isDonut = chartType === "donut" || innerRadius > 0; + const valueField = ctx.yFields[0]; + const formatter = ctx.valueFormatter; return { ...buildBaseOption(ctx), tooltip: { ...tooltipTokens(ui), trigger: "item", - formatter: "{b}: {c} ({d}%)", + formatter: + formatter && valueField + ? (params: { + name: string; + value: string | number; + percent: number; + }) => + `${escapeHtml(String(params.name))}: ${escapeHtml( + formatter(params.value, valueField), + )} (${params.percent}%)` + : "{b}: {c} ({d}%)", }, legend: ctx.showLegend ? { @@ -164,6 +216,10 @@ export function buildPieOption( type: "pie", radius: isDonut ? [`${innerRadius || 40}%`, "70%"] : "70%", center: ["60%", "50%"], + // Keep very small categories pointer-selectable. ECharts still reports + // labels/tooltips from the true values, while a three-degree sector is + // wide enough to survive CSS-pixel rounding on typical card sizes. + minAngle: 3, data: pieData, label: { show: showLabels, @@ -212,7 +268,7 @@ export function buildHorizontalBarOption( top: ctx.title ? "15%" : "5%", bottom: ctx.showLegend && hasMultipleSeries ? "15%" : "5%", }, - xAxis: { type: "value", ...axisCommon(ui) }, + xAxis: { type: "value", ...mergeAxisLabel(ui, valueAxisLabel(ctx)) }, yAxis: { type: "category", data: ctx.xData, @@ -229,6 +285,11 @@ export function buildHorizontalBarOption( stack: stacked ? "total" : undefined, itemStyle: { borderRadius: [0, 4, 4, 0] }, color: ctx.colors[idx % ctx.colors.length], + tooltip: ctx.valueFormatter + ? { + valueFormatter: tooltipValueFormatter(ctx.valueFormatter, key), + } + : undefined, })), }; } @@ -254,6 +315,10 @@ export function buildHeatmapOption( ctx: HeatmapContext, ): Record { const ui = ctx.ui ?? FALLBACK_UI_TOKENS; + const valueField = ctx.yFields[0]; + const formatter = ctx.valueFormatter; + const formatHeatmapValue = (value: number) => + formatter && valueField ? formatter(value, valueField) : String(value); return { ...buildBaseOption(ctx), tooltip: { @@ -265,7 +330,7 @@ export function buildHeatmapOption( // tooltip DOM, so data-derived labels must be escaped. const xLabel = escapeHtml(String(ctx.xData[xIdx] ?? xIdx)); const yLabel = escapeHtml(String(ctx.yAxisData[yIdx] ?? yIdx)); - return `${xLabel}, ${yLabel}: ${escapeHtml(String(value))}`; + return `${xLabel}, ${yLabel}: ${escapeHtml(formatHeatmapValue(value))}`; }, }, grid: { @@ -299,7 +364,14 @@ export function buildHeatmapOption( right: "2%", top: "center", textStyle: { color: ui.axisTitle }, + formatter: + formatter && valueField + ? (value: number) => formatter(value, valueField) + : undefined, inRange: { + // A visualMap gradient needs at least two stops; with a single-color + // palette, ramp from a light grey to that color instead of passing a + // one-entry array (which ECharts renders as a flat, unreadable scale). color: ctx.colors.length >= 2 ? ctx.colors : ["#f0f0f0", ctx.colors[0]], }, }, @@ -310,7 +382,7 @@ export function buildHeatmapOption( label: { show: ctx.showLabels, formatter: (params: { data: [number, number, number] }) => - String(params.data[2]), + formatHeatmapValue(params.data[2]), }, emphasis: { itemStyle: { @@ -331,11 +403,19 @@ export function buildCartesianOption( ctx: CartesianContext, ): Record { const ui = ctx.ui ?? FALLBACK_UI_TOKENS; - const { chartType, isTimeSeries, stacked, smooth, showSymbol, symbolSize } = - ctx; + const { + chartType, + isTimeSeries, + stacked, + smooth, + showSymbol, + symbolSize, + interactive, + } = ctx; const hasMultipleSeries = ctx.yFields.length > 1; const seriesType = chartType === "area" ? "line" : chartType; const isScatter = chartType === "scatter"; + const isLineLike = chartType === "line" || chartType === "area"; return { ...buildBaseOption(ctx), @@ -368,7 +448,7 @@ export function buildCartesianOption( yAxis: { type: "value", name: ctx.yFields.length === 1 ? formatLabel(ctx.yFields[0]) : undefined, - ...axisCommon(ui), + ...mergeAxisLabel(ui, valueAxisLabel(ctx)), }, series: ctx.yFields.map((key, idx) => ({ name: formatLabel(key), @@ -378,16 +458,187 @@ export function buildCartesianOption( : isTimeSeries ? createTimeSeriesData(ctx.xData, ctx.yDataMap[key]) : ctx.yDataMap[key], - smooth: chartType === "line" || chartType === "area" ? smooth : undefined, - showSymbol: - chartType === "line" || chartType === "area" ? showSymbol : undefined, + smooth: isLineLike ? smooth : undefined, + showSymbol: isLineLike ? showSymbol : undefined, symbol: isScatter ? "circle" : undefined, - symbolSize: isScatter ? symbolSize : undefined, + // Symbol size applies to line/area as well as scatter, so an interactive + // line can present a clickable point, not just a hairline. + symbolSize: isScatter || isLineLike ? symbolSize : undefined, + // Fire click events along the whole line stroke, not only on symbols, + // when the chart is interactive. No effect on non-line series. + triggerLineEvent: isLineLike && interactive ? true : undefined, areaStyle: chartType === "area" ? { opacity: 0.3 } : undefined, stack: stacked && chartType === "area" ? "total" : undefined, itemStyle: chartType === "bar" ? { borderRadius: [4, 4, 0, 0] } : undefined, color: ctx.colors[idx % ctx.colors.length], + tooltip: ctx.valueFormatter + ? { + valueFormatter: tooltipValueFormatter(ctx.valueFormatter, key), + } + : undefined, })), }; } + +// ============================================================================ +// Selection Emphasis (declarative cross-filter highlighting) +// ============================================================================ + +// Selection opacity stays local because it is not a theme token. +const DIMMED_OPACITY = 0.3; + +/** Opacity applied to selected (emphasized) data elements. */ +const SELECTED_OPACITY = 1; + +/** Options controlling {@link applySelectionEmphasis}. */ +interface SelectionEmphasisOptions { + /** Opacity for dimmed (non-selected) elements. @default 0.3 */ + dimmedOpacity?: number; + /** Opacity for emphasized (selected) elements. @default 1 */ + selectedOpacity?: number; +} + +/** + * Returns selected category names, or `null` when empty. + */ +function toSelectionSet( + selected: string | string[] | undefined, +): Set | null { + if (selected == null) return null; + const names = Array.isArray(selected) ? selected : [selected]; + const set = new Set( + names.map((name) => String(name)).filter((name) => name !== ""), + ); + return set.size > 0 ? set : null; +} + +/** + * Finds category-axis labels used to map bar positions to names. + * Built-in chart options expose at most one category axis. + * Horizontal and vertical bars put that category on different axes. + */ +function categoryNamesFromAxes( + option: Record, +): (string | number)[] | null { + for (const axisKey of ["xAxis", "yAxis"] as const) { + const axis = option[axisKey]; + if (axis !== null && typeof axis === "object" && !Array.isArray(axis)) { + const a = axis as Record; + if (a.type === "category" && Array.isArray(a.data)) { + return a.data as (string | number)[]; + } + } + } + return null; +} + +/** + * Returns a copy of a single data item with its `itemStyle.opacity` set. + * Object data items (e.g. pie `{ name, value }`) are spread and their existing + * `itemStyle` preserved; primitive data items (e.g. raw bar values) are wrapped + * into `{ value, itemStyle }`. + * per-datum `itemStyle` merges over the series-level `itemStyle` in ECharts, so + * styling such as bar `borderRadius` is retained. + */ +function withDatumOpacity(datum: unknown, opacity: number): unknown { + if (datum !== null && typeof datum === "object" && !Array.isArray(datum)) { + const d = datum as Record; + const prev = + d.itemStyle !== null && + typeof d.itemStyle === "object" && + !Array.isArray(d.itemStyle) + ? (d.itemStyle as Record) + : {}; + return { ...d, itemStyle: { ...prev, opacity } }; + } + return { value: datum as number | string, itemStyle: { opacity } }; +} + +/** + * Applies opacity to pie/bar categories. + */ +function emphasizeSeries( + series: unknown, + selected: Set, + dimmedOpacity: number, + selectedOpacity: number, + categoryNames: (string | number)[] | null, +): unknown { + if (series === null || typeof series !== "object" || Array.isArray(series)) { + return series; + } + const s = series as Record; + if (!Array.isArray(s.data)) return series; + + let nameAt: (datum: unknown, index: number) => string | undefined; + if (s.type === "pie") { + nameAt = (datum) => + datum !== null && typeof datum === "object" && "name" in datum + ? String((datum as Record).name) + : undefined; + } else if (s.type === "bar") { + // Bar data items are raw values; the category name lives on the category axis. + if (!categoryNames) return series; + nameAt = (_datum, index) => + categoryNames[index] !== undefined + ? String(categoryNames[index]) + : undefined; + } else { + return series; + } + + const data = (s.data as unknown[]).map((datum, index) => { + const name = nameAt(datum, index); + if (name === undefined) return datum; + const opacity = selected.has(name) ? selectedOpacity : dimmedOpacity; + return withDatumOpacity(datum, opacity); + }); + + return { ...s, data }; +} + +/** + * Pure, declarative selection-emphasis transform for a built ECharts `option`. + * + * Given one or more selected category names, returns a new `option` in which the + * matching data element(s) render at full prominence while the rest are dimmed + * via `itemStyle.opacity`. It is a **no-op** (returns the input unchanged) when + * `selected` is `undefined` or empty. + * + * @typeParam T - The option object type (typically `Record`). + * @param option - The ECharts option produced by one of the `build*Option` helpers. + * @param selected - The selected category name(s); `undefined`/empty means no emphasis. + * @param opts - Optional opacity overrides. See {@link SelectionEmphasisOptions}. + * @returns A new option with emphasis applied, or the original `option` when there is no selection. + */ +export function applySelectionEmphasis( + option: T, + selected: string | string[] | undefined, + opts: SelectionEmphasisOptions = {}, +): T { + const selectedSet = toSelectionSet(selected); + if (!selectedSet) return option; + + if (option === null || typeof option !== "object" || Array.isArray(option)) { + return option; + } + const opt = option as Record; + if (!Array.isArray(opt.series)) return option; + + const dimmedOpacity = opts.dimmedOpacity ?? DIMMED_OPACITY; + const selectedOpacity = opts.selectedOpacity ?? SELECTED_OPACITY; + const categoryNames = categoryNamesFromAxes(opt); + + const series = (opt.series as unknown[]).map((s) => + emphasizeSeries( + s, + selectedSet, + dimmedOpacity, + selectedOpacity, + categoryNames, + ), + ); + + return { ...opt, series } as T; +} diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index fba131ec8..3def1b913 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -38,6 +38,12 @@ export type ChartType = /** Data that can be passed to unified charts */ export type ChartData = Table | Record[]; +/** Formats a measure value for chart axes, tooltips, and value labels. */ +export type ChartValueFormatter = ( + value: string | number, + field: string, +) => string; + // ============================================================================ // Base Props (shared by all charts) // ============================================================================ @@ -89,6 +95,66 @@ export interface ChartBaseProps { /** Additional ECharts options to merge */ options?: Record; + + /** + * Formats measure values in the chart's built-in value axes, tooltips, and + * value labels. `field` is the corresponding `yKey`, so one formatter can + * select a different format for each series. + * + * For charts with multiple series on one value axis, axis ticks use the + * first `yKey`; each tooltip uses its own series field. + */ + valueFormatter?: ChartValueFormatter; + + /** + * Pointer-only: charts render to , so this does not fire for keyboard + * users. Provide a keyboard-accessible equivalent (e.g. a table row action) for + * the same action. + */ + onDataClick?: (datum: ChartClickDatum) => void; + + /** + * Controlled selection by category name. Matching data element(s) render at full + * prominence while the rest are dimmed. Drive it from your own state to reflect a + * cross-filter or selection. Categorical charts (bar, pie/donut) show emphasis; + * other chart types ignore it. + */ + selected?: string | string[]; +} + +// ============================================================================ +// Interaction / Click Events +// ============================================================================ + +/** + * A normalized description of a clicked chart element. + * + * In the common cross-filter case, {@link ChartClickDatum.name} carries the + * dimension value of the clicked element. + */ +export interface ChartClickDatum { + /** Category label of the clicked element — the dimension value in the common cross-filter case. */ + name: string; + /** + * The datum's scalar value: + * - bar / pie — the datum itself. + * - time-series / scatter — the y-component of the `[x, y]` point (see + * {@link ChartClickDatum.x} / {@link ChartClickDatum.y}). + * - heatmap — the cell value (the third entry of ECharts' + * `[xIndex, yIndex, value]` triple), *not* an axis index. + * - radar — `null`; the item holds one value per indicator, so there is no + * single scalar to report. Read the vector from {@link ChartClickDatum.raw}. + * + * `null` whenever there is no scalar value to surface. + */ + value: number | string | null; + x?: number | string; + y?: number | string; + seriesName?: string; + dataIndex: number; + seriesIndex: number; + // Untouched ECharts event params. + raw: unknown; } // ============================================================================ diff --git a/packages/appkit-ui/src/react/charts/utils.ts b/packages/appkit-ui/src/react/charts/utils.ts index cdd5c07a3..7e6d36bd2 100644 --- a/packages/appkit-ui/src/react/charts/utils.ts +++ b/packages/appkit-ui/src/react/charts/utils.ts @@ -1,3 +1,5 @@ +import type { ChartClickDatum } from "./types"; + // ============================================================================ // Chart Utility Functions // ============================================================================ @@ -31,29 +33,7 @@ export function toChartArray(data: unknown[]): (string | number)[] { return data.map(toChartValue); } -/** - * Formats a field name into a human-readable label. - * Handles camelCase, snake_case, acronyms, and ALL_CAPS. - * E.g., "totalSpend" -> "Total Spend", "user_name" -> "User Name", - * "userID" -> "User Id", "TOTAL_SPEND" -> "Total Spend" - */ -export function formatLabel(field: string): string { - return ( - field - // Handle consecutive uppercase followed by lowercase (e.g., HTTPUrl → HTTP Url) - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") - // Handle lowercase followed by uppercase (e.g., totalSpend → total Spend) - .replace(/([a-z])([A-Z])/g, "$1 $2") - // Replace underscores with spaces - .replace(/_/g, " ") - // Collapse multiple spaces into one - .replace(/\s+/g, " ") - // Normalize to title case - .toLowerCase() - .replace(/\b\w/g, (l) => l.toUpperCase()) - .trim() - ); -} +export { formatLabel } from "@/js"; /** * Escapes HTML special characters to prevent XSS. @@ -126,7 +106,233 @@ export function sortNumericAscending( } /** - * Sorts time-series data in ascending chronological order. + * Axis category labels for the chart being clicked, so index-addressed data + * (heatmap) can be resolved back to the labels the user actually sees. + */ +interface DatumAxisContext { + xLabels?: (string | number)[]; + yLabels?: (string | number)[]; +} + +interface ChartEventInstance { + getOption(): unknown; + convertToPixel( + finder: { seriesIndex: number }, + value: (string | number)[], + ): unknown; +} + +interface ResolvedLinePoint { + name: string; + value: [string | number, string | number]; + dataIndex: number; +} + +function resolveLineStrokePoint( + params: Record, + axes: DatumAxisContext, + instance?: ChartEventInstance, +): ResolvedLinePoint | null { + if ( + params.seriesType !== "line" || + params.value !== undefined || + !instance || + typeof params.seriesIndex !== "number" + ) { + return null; + } + + const event = + params.event !== null && typeof params.event === "object" + ? (params.event as Record) + : null; + const clickX = event?.offsetX; + if (typeof clickX !== "number") return null; + + try { + const option = instance.getOption(); + if (option === null || typeof option !== "object") return null; + + const series = (option as Record).series; + if (!Array.isArray(series)) return null; + + const seriesOption = series[params.seriesIndex]; + if ( + seriesOption === null || + typeof seriesOption !== "object" || + Array.isArray(seriesOption) + ) { + return null; + } + + const data = (seriesOption as Record).data; + if (!Array.isArray(data)) return null; + + let nearest: ResolvedLinePoint | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (let dataIndex = 0; dataIndex < data.length; dataIndex++) { + const item = data[dataIndex]; + const itemRecord = + item !== null && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : null; + const rawValue = itemRecord ? itemRecord.value : item; + + let x: string | number | undefined; + let y: string | number | undefined; + if (Array.isArray(rawValue)) { + if ( + (typeof rawValue[0] === "string" || + typeof rawValue[0] === "number") && + (typeof rawValue[1] === "string" || typeof rawValue[1] === "number") + ) { + x = rawValue[0]; + y = rawValue[1]; + } + } else if ( + (typeof rawValue === "string" || typeof rawValue === "number") && + axes.xLabels?.[dataIndex] !== undefined + ) { + x = axes.xLabels[dataIndex]; + y = rawValue; + } + if (x === undefined || y === undefined) continue; + + const pixel = instance.convertToPixel( + { seriesIndex: params.seriesIndex }, + [x, y], + ); + if (!Array.isArray(pixel) || typeof pixel[0] !== "number") continue; + + const distance = Math.abs(pixel[0] - clickX); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = { + name: + typeof itemRecord?.name === "string" ? itemRecord.name : String(x), + value: [x, y], + dataIndex, + }; + } + } + + return nearest; + } catch { + return null; + } +} + +/** + * Maps a raw ECharts click-event `params` object into a public + * {@link ChartClickDatum}. + * + * @param params - The raw ECharts click-event payload (untyped at our boundary). + * @param axes - Category labels used to resolve index-addressed heatmap data. + * @param instance - The chart instance used to resolve series-level line clicks. + * @returns A normalized, ECharts-free {@link ChartClickDatum}. + */ +export function mapToDatum( + params: unknown, + axes: DatumAxisContext = {}, + instance?: ChartEventInstance, +): ChartClickDatum { + const p = ( + params !== null && typeof params === "object" ? params : {} + ) as Record; + + const isScalar = (v: unknown): v is number | string => + typeof v === "number" || typeof v === "string"; + + const linePoint = resolveLineStrokePoint(p, axes, instance); + const rawValue = linePoint?.value ?? p.value; + const seriesType = + typeof p.seriesType === "string" ? p.seriesType : undefined; + + let x: number | string | undefined; + let y: number | string | undefined; + let value: number | string | null; + + if (seriesType === "heatmap" && Array.isArray(rawValue)) { + // `[xIndex, yIndex, value]`: report the cell value + const labelAt = ( + labels: (string | number)[] | undefined, + index: unknown, + ): number | string | undefined => { + if (!isScalar(index)) return undefined; + if (typeof index === "number" && labels?.[index] !== undefined) { + return labels[index]; + } + return index; + }; + x = labelAt(axes.xLabels, rawValue[0]); + y = labelAt(axes.yLabels, rawValue[1]); + value = isScalar(rawValue[2]) ? rawValue[2] : null; + } else if (seriesType === "radar") { + // A radar item holds one value per indicator; + value = null; + } else if (Array.isArray(rawValue)) { + // `[x, y]` (time-series / scatter): split values so callers don't have to re-parse `raw`. + if (isScalar(rawValue[0])) x = rawValue[0]; + if (isScalar(rawValue[1])) y = rawValue[1]; + value = y ?? null; + } else { + value = isScalar(rawValue) ? rawValue : null; + } + + const name = + typeof p.name === "string" + ? p.name + : (linePoint?.name ?? (x !== undefined ? String(x) : "")); + + const seriesName = + typeof p.seriesName === "string" ? p.seriesName : undefined; + + const dataIndex = + typeof p.dataIndex === "number" + ? p.dataIndex + : (linePoint?.dataIndex ?? -1); + const seriesIndex = typeof p.seriesIndex === "number" ? p.seriesIndex : -1; + + return { + name, + value, + x, + y, + seriesName, + dataIndex, + seriesIndex, + raw: params, + }; +} + +const DATE_STRING_PATTERN = /^\d{4}-\d{2}-\d{2}(?:$|[T\s])/; + +/** + * Converts a value to a number that can be compared chronologically, or null + * when it is not a date. This is the single definition of "chartable date" + * shared by field detection, value conversion and sorting: a shape-only check + * would classify "2025-01-01 nonsense" as a date field and then fail to place + * it on a time axis. + */ +export function toChronologicalValue(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (typeof value !== "string") return null; + if (!DATE_STRING_PATTERN.test(value)) return null; + + // Spark JSON_ARRAY timestamps commonly use a space between the date and + // time. Normalize that separator to the ISO form before parsing. + const timestamp = Date.parse(value.replace(" ", "T")); + return Number.isFinite(timestamp) ? timestamp : null; +} + +/** + * Sorts time-series data in ascending chronological order while preserving + * the correlation between each x value and its y values. Non-date category + * strings are intentionally left in their source order. */ export function sortTimeSeriesAscending( xData: (string | number)[], @@ -140,22 +346,29 @@ export function sortTimeSeriesAscending( return { xData, yDataMap }; } - const first = xData[0]; - const last = xData[xData.length - 1]; + const chronologicalValues = xData.map(toChronologicalValue); + if (chronologicalValues.some((value) => value === null)) { + return { xData, yDataMap }; + } - if (typeof first === "number" && typeof last === "number" && first > last) { - const indices = xData.map((_, i) => i); - indices.sort((a, b) => (xData[a] as number) - (xData[b] as number)); + const indices = xData.map((_, i) => i); + indices.sort( + (a, b) => + (chronologicalValues[a] as number) - (chronologicalValues[b] as number), + ); - const sortedXData = indices.map((i) => xData[i]); - const sortedYDataMap: Record = {}; - for (const key of yFields) { - const original = yDataMap[key]; - sortedYDataMap[key] = indices.map((i) => original[i]); - } + if (indices.every((originalIndex, index) => originalIndex === index)) { + return { xData, yDataMap }; + } - return { xData: sortedXData, yDataMap: sortedYDataMap }; + const sortedXData = indices.map((i) => xData[i]); + const sortedYDataMap: Record = { + ...yDataMap, + }; + for (const key of yFields) { + const original = yDataMap[key]; + sortedYDataMap[key] = indices.map((i) => original[i]); } - return { xData, yDataMap }; + return { xData: sortedXData, yDataMap: sortedYDataMap }; } diff --git a/packages/appkit-ui/src/react/hooks/__tests__/analytics-sse.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/analytics-sse.test.ts new file mode 100644 index 000000000..6f1757f5e --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/analytics-sse.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test, vi } from "vitest"; +import { + type AnalyticsSseHandlerContext, + GENERIC_LOAD_ERROR, + handleAnalyticsSseError, + handleAnalyticsSseMessage, + parseAnalyticsSseMessage, + userFacingFetchError, +} from "../analytics-sse"; + +function createContext(overrides: Partial = {}) { + const controller = new AbortController(); + const abort = vi.fn(() => controller.abort()); + const context: AnalyticsSseHandlerContext = { + source: "useAnalyticsQuery", + resource: { queryKey: "orders" }, + defaultExecutionError: "Unable to execute query", + unpublishOnMalformedMessage: false, + signal: controller.signal, + abort, + setLoading: vi.fn(), + setError: vi.fn(), + setErrorCode: vi.fn(), + onWarehouseStatus: vi.fn(), + onResult: vi.fn(), + unpublishWarehouseStatus: vi.fn(), + ...overrides, + }; + return { abort, context, controller }; +} + +describe("analytics SSE parsing", () => { + test("classifies warehouse status, normalized results, and structured errors", () => { + expect( + parseAnalyticsSseMessage( + JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 1200 }, + }), + "fallback", + ), + ).toEqual({ + kind: "warehouse-status", + status: { state: "STARTING", elapsedMs: 1200 }, + }); + + expect( + parseAnalyticsSseMessage( + JSON.stringify({ type: "result", metadata: { amount: {} } }), + "fallback", + ), + ).toEqual({ + kind: "result", + data: [], + payload: { type: "result", metadata: { amount: {} } }, + }); + + expect( + parseAnalyticsSseMessage( + JSON.stringify({ + type: "error", + message: "Query failed", + code: "UPSTREAM_ERROR", + errorCode: "STATEMENT_FAILED", + }), + "fallback", + ), + ).toEqual({ + kind: "error", + message: "Query failed", + code: "UPSTREAM_ERROR", + errorCode: "STATEMENT_FAILED", + }); + }); + + test("classifies malformed warehouse status and unknown payloads as invalid", () => { + expect( + parseAnalyticsSseMessage( + JSON.stringify({ type: "warehouse_status" }), + "fallback", + ), + ).toMatchObject({ + kind: "invalid", + reason: "malformed-warehouse-status", + }); + + expect( + parseAnalyticsSseMessage( + JSON.stringify({ type: "heartbeat" }), + "fallback", + ), + ).toMatchObject({ kind: "invalid", reason: "unrecognized" }); + }); +}); + +describe("analytics SSE handling", () => { + test("applies common success state and delegates result-specific fields", async () => { + const { context } = createContext(); + + await handleAnalyticsSseMessage( + JSON.stringify({ + type: "result", + data: [{ amount: 42 }], + metadata: { amount: { type: "LONG" } }, + }), + context, + ); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.onResult).toHaveBeenCalledWith({ + kind: "result", + data: [{ amount: 42 }], + payload: { + type: "result", + data: [{ amount: 42 }], + metadata: { amount: { type: "LONG" } }, + }, + }); + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + expect(context.setError).not.toHaveBeenCalled(); + }); + + test("surfaces server errors and their structured code", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { abort, context } = createContext(); + + await handleAnalyticsSseMessage( + JSON.stringify({ + type: "error", + error: "Server is at capacity", + code: "UPSTREAM_ERROR", + errorCode: "WAREHOUSE_CAPACITY", + }), + context, + ); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.setError).toHaveBeenCalledWith("Server is at capacity"); + expect(context.setErrorCode).toHaveBeenCalledWith("WAREHOUSE_CAPACITY"); + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + expect(abort).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "[useAnalyticsQuery] Code: UPSTREAM_ERROR, Message: Server is at capacity", + ); + errorSpy.mockRestore(); + }); + + test("terminates malformed streams with the generic user-facing error", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { abort, context, controller } = createContext(); + + await handleAnalyticsSseMessage("not-json{", context); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.setError).toHaveBeenCalledWith(GENERIC_LOAD_ERROR); + expect(context.unpublishWarehouseStatus).not.toHaveBeenCalled(); + expect(abort).toHaveBeenCalledOnce(); + expect(controller.signal.aborted).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + "[useAnalyticsQuery] Malformed message received", + expect.any(SyntaxError), + ); + warnSpy.mockRestore(); + }); + + test("retains metric-view warehouse cleanup for malformed streams", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { context } = createContext({ + source: "useMetricView", + unpublishOnMalformedMessage: true, + }); + + await handleAnalyticsSseMessage("not-json{", context); + + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + warnSpy.mockRestore(); + }); + + test("maps transport failures and ignores errors after abort", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { context, controller } = createContext(); + + handleAnalyticsSseError(new Error("Failed to fetch"), context); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.setError).toHaveBeenCalledWith( + "Network error. Please check your connection.", + ); + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + + vi.mocked(context.setError).mockClear(); + controller.abort(); + handleAnalyticsSseError(new Error("late failure"), context); + expect(context.setError).not.toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); +}); + +test("maps timeout and unknown failures to the existing user-facing messages", () => { + const timeout = new Error("aborted"); + timeout.name = "AbortError"; + + expect(userFacingFetchError(timeout)).toBe( + "Request timed out, please try again", + ); + expect(userFacingFetchError(new Error("other"))).toBe(GENERIC_LOAD_ERROR); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts new file mode 100644 index 000000000..4b8de584a --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts @@ -0,0 +1,774 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +let lastConnectArgs: any = null; +let capturedCallbacks: { + onMessage?: (msg: { data: string }) => void; + onError?: (err: Error) => void; + signal?: AbortSignal; +} = {}; + +// Mock connectSSE so the hook does not attempt a real network request. +// Capture both the full args (used by the payload/refetch tests) and the +// individual callbacks/signal (used by the result/error and late-envelope +// tests). The hook ignores the return value. +const mockConnectSSE = vi.fn((args: any): unknown => { + lastConnectArgs = args; + capturedCallbacks = { + onMessage: args?.onMessage, + onError: args?.onError, + signal: args?.signal, + }; + return () => {}; +}); + +vi.mock("@/js", () => ({ + connectSSE: (...args: unknown[]) => mockConnectSSE(...(args as [any])), + ArrowClient: {}, +})); + +vi.mock("../use-query-hmr", () => ({ + useQueryHMR: vi.fn(), +})); + +// Mock the warehouse-status publisher so we can observe the publish-only +// side-channel (useMetricView surfaces warehouse readiness ONLY by publishing +// to the ResourceStatusProvider — it never adds a field to its result). The +// two spies are stable across renders, mirroring the real hook's useCallback +// contract, so start()'s identity doesn't churn. +const mockPublishWarehouseStatus = vi.fn(); +const mockUnpublishWarehouseStatus = vi.fn(); +vi.mock("../use-analytics-warehouse-status", () => ({ + useAnalyticsWarehousePublisher: () => ({ + publish: mockPublishWarehouseStatus, + unpublish: mockUnpublishWarehouseStatus, + }), +})); + +import { useMetricView } from "../use-metric-view"; + +function markAborted() { + const sig = capturedCallbacks.signal; + if (!sig) throw new Error("signal not captured yet"); + Object.defineProperty(sig, "aborted", { value: true, configurable: true }); +} + +describe("useMetricView", () => { + beforeEach(() => { + vi.clearAllMocks(); + lastConnectArgs = null; + capturedCallbacks = {}; + mockPublishWarehouseStatus.mockClear(); + mockUnpublishWarehouseStatus.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("POSTs the metric route with only the defined body fields on mount", () => { + renderHook(() => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + limit: 100, + }), + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + expect(String(lastConnectArgs.url)).toContain( + "/api/analytics/metric/orders", + ); + // Only defined fields are serialized — undefined filter/timeGrain/ + // timeDimension/orderBy are omitted from the body. + expect(JSON.parse(lastConnectArgs.payload)).toEqual({ + measures: ["revenue"], + dimensions: ["region"], + limit: 100, + }); + }); + + test("starts one request under React Strict Mode", () => { + renderHook( + () => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + }), + { wrapper: StrictMode }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + expect(JSON.parse(lastConnectArgs.payload)).toEqual({ + measures: ["revenue"], + dimensions: ["region"], + }); + expect(lastConnectArgs.signal.aborted).toBe(false); + }); + + test("still aborts the active request after a genuine unmount", async () => { + const { unmount } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + const signal = lastConnectArgs.signal as AbortSignal; + + expect(signal.aborted).toBe(false); + unmount(); + + await waitFor(() => expect(signal.aborted).toBe(true)); + }); + + test("does not start until autoStart becomes true and keeps it out of the request body", () => { + const { rerender } = renderHook( + ({ autoStart }: { autoStart: boolean }) => + useMetricView("orders", { + measures: ["revenue"], + autoStart, + }), + { initialProps: { autoStart: false } }, + ); + + expect(mockConnectSSE).not.toHaveBeenCalled(); + + rerender({ autoStart: true }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + expect(JSON.parse(lastConnectArgs.payload)).toEqual({ + measures: ["revenue"], + }); + expect(JSON.parse(lastConnectArgs.payload)).not.toHaveProperty("autoStart"); + }); + + test("aborts an active request when autoStart becomes false", () => { + const { rerender } = renderHook( + ({ autoStart }: { autoStart: boolean }) => + useMetricView("orders", { + measures: ["revenue"], + autoStart, + }), + { initialProps: { autoStart: true } }, + ); + const signal = mockConnectSSE.mock.calls[0][0].signal as AbortSignal; + + expect(signal.aborted).toBe(false); + + rerender({ autoStart: false }); + + expect(signal.aborted).toBe(true); + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + }); + + test("serializes orderBy into the POST body when provided", () => { + renderHook(() => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + orderBy: [ + { field: "revenue", direction: "DESC" }, + { field: "region", direction: "ASC" }, + ], + }), + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + expect(JSON.parse(lastConnectArgs.payload)).toEqual({ + measures: ["revenue"], + dimensions: ["region"], + orderBy: [ + { field: "revenue", direction: "DESC" }, + { field: "region", direction: "ASC" }, + ], + }); + }); + + test("omits orderBy from the body when undefined", () => { + renderHook(() => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + orderBy: undefined, + }), + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + // orderBy must be absent from the body, not present-and-empty. + expect(JSON.parse(lastConnectArgs.payload)).toEqual({ + measures: ["revenue"], + dimensions: ["region"], + }); + expect(JSON.parse(lastConnectArgs.payload)).not.toHaveProperty("orderBy"); + }); + + test("refetches when orderBy changes", () => { + const { rerender } = renderHook( + ({ orderDir }: { orderDir: "ASC" | "DESC" }) => + useMetricView("orders", { + measures: ["revenue"], + orderBy: [{ field: "revenue", direction: orderDir }], + }), + { initialProps: { orderDir: "DESC" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ orderDir: "ASC" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + }); + + test("does not refetch when orderBy array is equal by value across renders", () => { + const { rerender } = renderHook( + ({ orderDir }: { orderDir: "DESC" }) => + useMetricView("orders", { + measures: ["revenue"], + orderBy: [{ field: "revenue", direction: orderDir }], + }), + { initialProps: { orderDir: "DESC" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ orderDir: "DESC" }); + rerender({ orderDir: "DESC" }); + + // Structurally equal orderBy should not trigger a refetch because + // the memo compares by JSON.stringify value, not object identity. + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + }); + + test("surfaces a type:result payload as data and reads its per-column metadata", async () => { + const { result } = renderHook(() => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + }), + ); + + const metadata = { + revenue: { type: "DECIMAL", display_name: "Revenue", format: "currency" }, + region: { type: "STRING", display_name: "Region" }, + }; + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ revenue: 100, region: "EMEA" }], + metadata, + }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 100, region: "EMEA" }]); + }); + expect(result.current.metadata).toEqual(metadata); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("leaves metadata undefined when the result payload omits it", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ revenue: 1 }] }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 1 }]); + }); + expect(result.current.metadata).toBeUndefined(); + }); + + test("treats a non-object metadata (null/array) as absent", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ revenue: 1 }], + // Malformed wire value — must not be surfaced as a metadata map. + metadata: ["not", "an", "object"], + }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 1 }]); + }); + expect(result.current.metadata).toBeUndefined(); + }); + + test("a successful result after a transient error clears the stale error", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + // First: an error envelope sets error + errorCode. + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "error", + error: "boom", + errorCode: "UPSTREAM_ERROR", + }), + }); + }); + await waitFor(() => expect(result.current.error).toBe("boom")); + expect(result.current.errorCode).toBe("UPSTREAM_ERROR"); + + // Then: a successful result must clear both, so error-first consumers show + // the fresh data instead of the stale error. + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ revenue: 7 }] }), + }); + }); + await waitFor(() => expect(result.current.data).toEqual([{ revenue: 7 }])); + expect(result.current.error).toBeNull(); + expect(result.current.errorCode).toBeNull(); + }); + + test("normalizes an empty result message (no data field) to []", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ data: JSON.stringify({ type: "result" }) }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([]); + }); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("exposes the latest warehouse_status locally and publishes every status", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.warehouseStatus).toBeNull(); + // start() registers the slot with a null status (see the publish-only + // side-channel) before any event arrives. + expect(mockPublishWarehouseStatus).toHaveBeenCalledWith(null); + + const stopped = { state: "STOPPED", elapsedMs: 200 }; + const starting = { state: "STARTING", elapsedMs: 1200 }; + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "warehouse_status", status: stopped }), + }); + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "warehouse_status", status: starting }), + }); + }); + + // The same event drives both per-hook feedback and the optional shared + // provider's global "warehouse starting…" indicator. + expect(mockPublishWarehouseStatus).toHaveBeenCalledWith(stopped); + expect(mockPublishWarehouseStatus).toHaveBeenCalledWith(starting); + expect(mockUnpublishWarehouseStatus).not.toHaveBeenCalled(); + expect(result.current.warehouseStatus).toEqual(starting); + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + test("resets local warehouse status when a new request starts", () => { + const { result, rerender } = renderHook( + ({ region }: { region: string }) => + useMetricView("orders", { + measures: ["revenue"], + filter: { member: "region", operator: "equals", values: [region] }, + }), + { initialProps: { region: "EMEA" } }, + ); + + const status = { state: "STARTING", elapsedMs: 1200 }; + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "warehouse_status", status }), + }); + }); + expect(result.current.warehouseStatus).toEqual(status); + + rerender({ region: "APAC" }); + + expect(result.current.warehouseStatus).toBeNull(); + expect(result.current.loading).toBe(true); + expect(mockPublishWarehouseStatus).toHaveBeenLastCalledWith(null); + }); + + test("unpublishes warehouse status once the result arrives", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 500 }, + }), + }); + }); + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ revenue: 1 }] }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 1 }]); + }); + // The indicator must clear once the warehouse is ready and rows land. + expect(mockUnpublishWarehouseStatus).toHaveBeenCalled(); + }); + + test("a malformed warehouse_status event errors and unpublishes rather than publishing", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + // Baseline publish(null) from start(); a malformed event must not publish + // a status on top of it. + const publishCallsBefore = mockPublishWarehouseStatus.mock.calls.length; + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "warehouse_status" }), + }); + }); + + await waitFor(() => { + expect(result.current.error).toBe( + "Unable to load data, please try again", + ); + }); + expect(result.current.loading).toBe(false); + expect(mockPublishWarehouseStatus.mock.calls.length).toBe( + publishCallsBefore, + ); + expect(mockUnpublishWarehouseStatus).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + test("a server error event exposes both the message and the structured errorCode", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "error", + error: "Metric view is not defined", + code: "UPSTREAM_ERROR", + errorCode: "UNKNOWN_METRIC_KEY", + }), + }); + }); + + await waitFor(() => { + expect(result.current.error).toBe("Metric view is not defined"); + }); + expect(result.current.errorCode).toBe("UNKNOWN_METRIC_KEY"); + expect(result.current.loading).toBe(false); + + errorSpy.mockRestore(); + }); + + test("a malformed (non-JSON) SSE payload clears loading and surfaces an error", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ data: "not-json{" }); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBe("Unable to load data, please try again"); + expect(result.current.data).toBeNull(); + + warnSpy.mockRestore(); + }); + + test("maps an onError network failure to a user-facing message", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onError(new Error("Failed to fetch")); + }); + + await waitFor(() => { + expect(result.current.error).toBe( + "Network error. Please check your connection.", + ); + }); + expect(result.current.loading).toBe(false); + + errorSpy.mockRestore(); + }); + + test("does not refetch when the options are structurally equal across renders", () => { + const { rerender } = renderHook( + ({ region }: { region: string }) => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + filter: { member: "region", operator: "equals", values: [region] }, + }), + { initialProps: { region: "EMEA" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ region: "EMEA" }); + rerender({ region: "EMEA" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + }); + + test("refetches and aborts the prior stream when a measure changes", () => { + const { rerender } = renderHook( + ({ measure }: { measure: string }) => + useMetricView("orders", { measures: [measure] }), + { initialProps: { measure: "revenue" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + const firstSignal = mockConnectSSE.mock.calls[0][0].signal as AbortSignal; + expect(firstSignal.aborted).toBe(false); + + rerender({ measure: "order_count" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + // The prior request's controller was aborted before the new one started. + expect(firstSignal.aborted).toBe(true); + expect(JSON.parse(mockConnectSSE.mock.calls[1][0].payload)).toEqual({ + measures: ["order_count"], + }); + }); + + test("refetches when the filter changes", () => { + const { rerender } = renderHook( + ({ region }: { region: string }) => + useMetricView("orders", { + measures: ["revenue"], + filter: { member: "region", operator: "equals", values: [region] }, + }), + { initialProps: { region: "EMEA" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ region: "APAC" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + }); + + test("preserves data and metadata while a filter change revalidates", async () => { + const { result, rerender } = renderHook( + ({ region }: { region: string }) => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + filter: { member: "region", operator: "equals", values: [region] }, + }), + { initialProps: { region: "EMEA" } }, + ); + const metadata = { + revenue: { type: "DECIMAL", display_name: "Revenue" }, + region: { type: "STRING", display_name: "Region" }, + }; + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ revenue: 100, region: "EMEA" }], + metadata, + }), + }); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ region: "APAC" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(true); + expect(result.current.data).toEqual([{ revenue: 100, region: "EMEA" }]); + expect(result.current.metadata).toEqual(metadata); + expect(result.current.error).toBeNull(); + }); + + test("clears data and metadata when selected columns change", async () => { + const { result, rerender } = renderHook( + ({ measure }: { measure: string }) => + useMetricView("orders", { measures: [measure] }), + { initialProps: { measure: "revenue" } }, + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ revenue: 100 }], + metadata: { revenue: { type: "DECIMAL" } }, + }), + }); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ measure: "order_count" }); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.metadata).toBeUndefined(); + }); + + test("clears data and metadata when the metric key changes", async () => { + const { result, rerender } = renderHook( + ({ metricKey }: { metricKey: string }) => + useMetricView(metricKey, { measures: ["revenue"] }), + { initialProps: { metricKey: "orders" } }, + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ revenue: 100 }], + metadata: { revenue: { type: "DECIMAL" } }, + }), + }); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ metricKey: "customers" }); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.metadata).toBeUndefined(); + }); + + test("refetches when the timeGrain changes", () => { + const { rerender } = renderHook( + ({ grain }: { grain: string }) => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["order_date"], + timeDimension: "order_date", + timeGrain: grain, + }), + { initialProps: { grain: "day" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ grain: "month" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + }); + + test("throws when the metric key is empty", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => + renderHook(() => useMetricView("", { measures: ["revenue"] })), + ).toThrow(/must be a non-empty string/); + + errorSpy.mockRestore(); + }); + + describe("aborted controller", () => { + test("ignores a late warehouse_status envelope after the controller was aborted", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + await waitFor(() => expect(capturedCallbacks.signal).toBeDefined()); + const publishCallsBefore = mockPublishWarehouseStatus.mock.calls.length; + + markAborted(); + + act(() => { + capturedCallbacks.onMessage?.({ + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 1200 }, + }), + }); + }); + + expect(result.current.warehouseStatus).toBeNull(); + expect(mockPublishWarehouseStatus).toHaveBeenCalledTimes( + publishCallsBefore, + ); + }); + + test("ignores a late result envelope after the controller was aborted", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + await waitFor(() => expect(capturedCallbacks.signal).toBeDefined()); + + markAborted(); + + act(() => { + capturedCallbacks.onMessage?.({ + data: JSON.stringify({ type: "result", data: [{ revenue: 99 }] }), + }); + }); + + expect(result.current.data).toBeNull(); + }); + + test("ignores a late error envelope after the controller was aborted", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + await waitFor(() => expect(capturedCallbacks.signal).toBeDefined()); + + markAborted(); + + act(() => { + capturedCallbacks.onMessage?.({ + data: JSON.stringify({ + type: "error", + error: "The operation was aborted.", + code: "UPSTREAM_ERROR", + }), + }); + }); + + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.types.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.types.test.ts new file mode 100644 index 000000000..22c7a5c08 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.types.test.ts @@ -0,0 +1,135 @@ +import path from "node:path"; +import ts from "typescript"; +import { expect, test } from "vitest"; + +const packageRoot = + path.basename(process.cwd()) === "appkit-ui" + ? process.cwd() + : path.join(process.cwd(), "packages", "appkit-ui"); + +function compileTypeProbe(source: string): readonly ts.Diagnostic[] { + const configPath = path.join(packageRoot, "tsconfig.json"); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + packageRoot, + ); + parsed.options.types = [...(parsed.options.types ?? []), "vite/client"]; + const filename = path.join(packageRoot, "__type-tests__", "metric-view.ts"); + const host = ts.createCompilerHost(parsed.options); + const getSourceFile = host.getSourceFile.bind(host); + + host.fileExists = (candidate) => + candidate === filename || ts.sys.fileExists(candidate); + host.readFile = (candidate) => + candidate === filename ? source : ts.sys.readFile(candidate); + host.getSourceFile = (candidate, languageVersion, onError, shouldCreate) => + candidate === filename + ? ts.createSourceFile( + candidate, + source, + languageVersion, + true, + ts.ScriptKind.TS, + ) + : getSourceFile(candidate, languageVersion, onError, shouldCreate); + + const program = ts.createProgram([filename], parsed.options, host); + return ts.getPreEmitDiagnostics(program); +} + +test("useMetricView keeps omitted dimensions out of rows and requires a grain target", () => { + const diagnostics = compileTypeProbe(` + import { useMetricView } from "../src/react/hooks/use-metric-view"; + import type { + MetricOrderBy, + UseMetricViewOptions, + UseMetricViewResult, + } from "../src/react/hooks/types"; + + declare module "../src/react/hooks/types" { + interface MetricRegistry { + revenue: { + measures: { arr: string | null; mrr: string | null }; + dimensions: { region: string | null; created_at: string | null }; + measureKeys: "arr" | "mrr"; + dimensionKeys: "region" | "created_at"; + timeGrains: "day" | "month"; + metadata: { + measures: {}; + dimensions: { + region: { type: "string" }; + created_at: { + type: "timestamp"; + time_grain: readonly ["day", "month"]; + }; + }; + }; + }; + } + } + + type MeasureOnlyResult = ReturnType< + typeof useMetricView<"revenue", readonly ["arr"]> + >; + declare const result: MeasureOnlyResult; + const expected: UseMetricViewResult> = result; + void expected; + // @ts-expect-error region was not selected + result.data?.[0]?.region; + + type TimeOptions = UseMetricViewOptions< + "revenue", + readonly ["arr"], + readonly ["created_at"] + >; + const valid: TimeOptions = { + measures: ["arr"], + dimensions: ["created_at"], + timeDimension: "created_at", + timeGrain: "month", + }; + void valid; + + const reusableOrderBy: ReadonlyArray> = [ + { field: "arr", direction: "DESC" }, + { field: "created_at" }, + ]; + const ordered: TimeOptions = { + measures: ["arr"], + dimensions: ["created_at"], + orderBy: reusableOrderBy, + }; + void ordered; + + const unselectedOrderBy: ReadonlyArray> = [{ field: "mrr" }]; + // @ts-expect-error mrr was not selected + const invalidOrder: TimeOptions = { measures: ["arr"], dimensions: ["created_at"], orderBy: unselectedOrderBy }; + void invalidOrder; + + const broadOrderBy: MetricOrderBy[] = [{ field: "arr", direction: "DESC" }]; + // @ts-expect-error MetricOrderBy does not prove that its fields were selected + const broadOrder: TimeOptions = { measures: ["arr"], dimensions: ["created_at"], orderBy: broadOrderBy }; + void broadOrder; + + // @ts-expect-error timeGrain requires timeDimension + const missingTarget: TimeOptions = { measures: ["arr"], dimensions: ["created_at"], timeGrain: "month" }; + void missingTarget; + + type NoDimensionOptions = UseMetricViewOptions< + "revenue", + readonly ["arr"], + readonly [] + >; + // @ts-expect-error timeDimension must be selected in dimensions + const unselectedTarget: NoDimensionOptions = { measures: ["arr"], timeDimension: "created_at", timeGrain: "month" }; + void unselectedTarget; + `); + + expect( + diagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ).toEqual([]); +}); diff --git a/packages/appkit-ui/src/react/hooks/analytics-sse.ts b/packages/appkit-ui/src/react/hooks/analytics-sse.ts new file mode 100644 index 000000000..608adb450 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/analytics-sse.ts @@ -0,0 +1,216 @@ +import type { WarehouseStatus } from "./types"; + +export const GENERIC_LOAD_ERROR = "Unable to load data, please try again"; + +export function getDevMode(): string { + const dev = new URL(window.location.href).searchParams.get("dev"); + return dev ? `?dev=${dev}` : ""; +} + +/** Map a fetch/SSE transport error to a user-facing message. */ +export function userFacingFetchError(error: unknown): string { + if (error instanceof Error) { + if (error.name === "AbortError") { + return "Request timed out, please try again"; + } + if (error.message.includes("Failed to fetch")) { + return "Network error. Please check your connection."; + } + } + return GENERIC_LOAD_ERROR; +} + +interface WarehouseStatusMessage { + kind: "warehouse-status"; + status: WarehouseStatus; +} + +export interface AnalyticsSseResultMessage { + kind: "result"; + data: unknown[]; + payload: Record; +} + +interface AnalyticsSseErrorMessage { + kind: "error"; + message: string; + errorCode: string | null; + code: unknown; +} + +interface InvalidAnalyticsSseMessage { + kind: "invalid"; + reason: "malformed-warehouse-status" | "unrecognized"; + payload: unknown; +} + +type AnalyticsSseMessage = + | WarehouseStatusMessage + | AnalyticsSseResultMessage + | AnalyticsSseErrorMessage + | InvalidAnalyticsSseMessage; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus { + return ( + typeof value === "object" && + value !== null && + typeof (value as WarehouseStatus).state === "string" + ); +} + +/** + * Parse and classify the deliberately loose analytics SSE wire format. + * Result rows normalize to an empty array so hook state remains `T | null`. + */ +export function parseAnalyticsSseMessage( + data: string, + defaultExecutionError: string, +): AnalyticsSseMessage { + const parsed: unknown = JSON.parse(data); + + if (!isRecord(parsed)) { + return { kind: "invalid", reason: "unrecognized", payload: parsed }; + } + + if (parsed.type === "warehouse_status") { + if (!isWarehouseStatusPayload(parsed.status)) { + return { + kind: "invalid", + reason: "malformed-warehouse-status", + payload: parsed, + }; + } + return { kind: "warehouse-status", status: parsed.status }; + } + + if (parsed.type === "result") { + return { + kind: "result", + data: Array.isArray(parsed.data) ? parsed.data : [], + payload: parsed, + }; + } + + if (parsed.type === "error" || parsed.error || parsed.code) { + const message = + (typeof parsed.error === "string" && parsed.error) || + (typeof parsed.message === "string" && parsed.message) || + defaultExecutionError; + return { + kind: "error", + message, + errorCode: typeof parsed.errorCode === "string" ? parsed.errorCode : null, + code: parsed.code, + }; + } + + return { kind: "invalid", reason: "unrecognized", payload: parsed }; +} + +export interface AnalyticsSseHandlerContext { + source: "useAnalyticsQuery" | "useMetricView"; + resource: Record; + defaultExecutionError: string; + unpublishOnMalformedMessage: boolean; + signal: AbortSignal; + abort: () => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setErrorCode: (code: string | null) => void; + onWarehouseStatus: (status: WarehouseStatus) => void; + onResult: (message: AnalyticsSseResultMessage) => void; + unpublishWarehouseStatus: () => void; +} + +function failWithGenericError(ctx: AnalyticsSseHandlerContext): void { + ctx.setLoading(false); + ctx.setError(GENERIC_LOAD_ERROR); + ctx.unpublishWarehouseStatus(); +} + +/** + * Apply the state transitions shared by analytics-query and metric-view SSE + * messages while delegating their distinct result/status state to callbacks. + */ +export async function handleAnalyticsSseMessage( + data: string, + ctx: AnalyticsSseHandlerContext, +): Promise { + if (ctx.signal.aborted) return; + + try { + const message = parseAnalyticsSseMessage(data, ctx.defaultExecutionError); + + if (message.kind === "warehouse-status") { + ctx.onWarehouseStatus(message.status); + return; + } + + if (message.kind === "result") { + ctx.setLoading(false); + ctx.onResult(message); + ctx.unpublishWarehouseStatus(); + return; + } + + if (message.kind === "error") { + ctx.setLoading(false); + ctx.setError(message.message); + ctx.unpublishWarehouseStatus(); + if (message.errorCode !== null) { + ctx.setErrorCode(message.errorCode); + } + if (message.code) { + console.error( + `[${ctx.source}] Code: ${String(message.code)}, Message: ${message.message}`, + ); + } + return; + } + + if (message.reason === "malformed-warehouse-status") { + console.error( + `[${ctx.source}] Malformed warehouse_status event`, + message.payload, + ); + } else { + console.error( + `[${ctx.source}] Unrecognized SSE payload`, + message.payload, + ); + } + failWithGenericError(ctx); + } catch (error) { + console.warn(`[${ctx.source}] Malformed message received`, error); + ctx.setLoading(false); + ctx.setError(GENERIC_LOAD_ERROR); + if (ctx.unpublishOnMalformedMessage) { + ctx.unpublishWarehouseStatus(); + } + ctx.abort(); + } +} + +/** Apply the shared terminal state for an SSE connection failure. */ +export function handleAnalyticsSseError( + error: unknown, + ctx: AnalyticsSseHandlerContext, +): void { + if (ctx.signal.aborted) return; + + ctx.setLoading(false); + ctx.unpublishWarehouseStatus(); + + if (error instanceof Error) { + console.error(`[${ctx.source}] Error`, { + ...ctx.resource, + error: error.message, + stack: error.stack, + }); + } + ctx.setError(userFacingFetchError(error)); +} diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index b110c3845..9e4d51716 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -7,12 +7,26 @@ export { } from "../resource-status-indicator"; export type { AnalyticsFormat, + GrainsForSelectedTimeDims, + InferDimensionKeys, + InferMeasureKeys, + InferMetricRow, InferResultByFormat, InferRowType, InferServingChunk, InferServingRequest, InferServingResponse, + InferTimeDimensionKeys, + InferTimeGrains, + MetricFilter, + MetricFilterOperatorName, + MetricKey, + MetricOrderBy, + MetricOrderDirection, + MetricPredicate, MetricRegistry, + MetricViewColumnDisplay, + PickMetricRow, PluginRegistry, QueryRegistry, ServingAlias, @@ -20,6 +34,8 @@ export type { TypedArrowTable, UseAnalyticsQueryOptions, UseAnalyticsQueryResult, + UseMetricViewOptions, + UseMetricViewResult, WarehouseState, WarehouseStatus, } from "./types"; @@ -35,6 +51,7 @@ export { type UseChartDataResult, useChartData, } from "./use-chart-data"; +export { useMetricView } from "./use-metric-view"; export { useIsMobile } from "./use-mobile"; export { usePluginClientConfig } from "./use-plugin-config"; export { diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index 6bce4a478..48bb9b676 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -1,4 +1,5 @@ import type { Table } from "apache-arrow"; +import type { MetricViewColumnDisplay } from "shared"; // ============================================================================ // Data Format Types @@ -297,9 +298,168 @@ export type InferServingRequest = // Metric View Registry // ============================================================================ -/** - * Metric view registry populated through module augmentation by the generated - * `metric-views.ts` file. - */ +/** Metric view registry for type-safe metric keys */ // biome-ignore lint/suspicious/noEmptyInterface: intentionally empty — populated via module augmentation (generated metric-views.ts) export interface MetricRegistry {} + +export type MetricKey = AugmentedRegistry extends never + ? string + : AugmentedRegistry; + +export type InferMeasureKeys = K extends AugmentedRegistry + ? MetricRegistry[K] extends { measureKeys: infer M } + ? M + : string + : string; + +export type InferDimensionKeys = K extends AugmentedRegistry + ? MetricRegistry[K] extends { dimensionKeys: infer D } + ? D + : string + : string; + +export type InferTimeGrains = K extends AugmentedRegistry + ? MetricRegistry[K] extends { timeGrains: infer G } + ? G + : string + : string; + +/** + * Infers the full row shape (every measure + dimension) from registry + * otherwise a total `Record`. + */ +export type InferMetricRow = K extends AugmentedRegistry + ? MetricRegistry[K] extends { + measures: infer Meas; + dimensions: infer Dim; + } + ? Meas & Dim + : Record + : Record; + +export type PickMetricRow< + K, + M extends ReadonlyArray, + D extends ReadonlyArray, +> = K extends AugmentedRegistry + ? MetricRegistry[K] extends { + measures: infer Meas; + dimensions: infer Dim; + } + ? Pick> & + Pick> + : Record + : Record; + +type MetricDimensionMeta = K extends AugmentedRegistry + ? MetricRegistry[K] extends { metadata: { dimensions: infer DM } } + ? DM + : never + : never; + +/** + * The dimension keys of K that are TEMPORAL — i.e. carry a `time_grain` tuple in + * the generated metadata. Only these can be a `timeDimension`. + * Degrades to `string` for an unknown key. + */ +export type InferTimeDimensionKeys = + K extends AugmentedRegistry + ? { + [P in keyof MetricDimensionMeta]: MetricDimensionMeta[P] extends { + time_grain: unknown; + } + ? P + : never; + }[keyof MetricDimensionMeta] + : string; + +/** + * The valid grains for the SELECTED temporal dimensions `D` of K — the union of + * each selected temporal dimension's `time_grain` tuple. In practice grains are + * type-driven (all `timestamp` dims share one set, all `date` dims another), so + * the union is exactly the grains applicable to the query. Falls back to the + * metric's whole `timeGrains` union (or `string`) for an unknown/degraded key. + */ +export type GrainsForSelectedTimeDims< + K, + D extends ReadonlyArray, +> = K extends AugmentedRegistry + ? { + [P in Extract< + D[number], + keyof MetricDimensionMeta + >]: MetricDimensionMeta[P] extends { + time_grain: infer G extends readonly unknown[]; + } + ? G[number] + : never; + }[Extract>] + : string; + +export type { + MetricFilter, + MetricFilterOperatorName, + MetricOrderBy, + MetricOrderDirection, + MetricPredicate, + MetricViewColumnDisplay, +} from "@/js"; + +import type { MetricFilter, MetricOrderBy } from "@/js"; + +/** + * Options for configuring a `useMetricView` query. + * + * Generic over the selected measure tuple `M` and dimension tuple `D` so the + * returned row shape ({@link PickMetricRow}) narrows to exactly the columns the + * query asked for. `timeDimension` must be a SELECTED, TEMPORAL dimension, and + * `timeGrain` is correlated to the grains valid for those dimensions — so + * bucketing a non-temporal dimension is a type error. `orderBy` is constrained + * to SELECTED measures and dimensions, so ordering by an unselected column is a + * compile-time error. + */ +type MetricViewTimeOptions< + K extends MetricKey, + D extends ReadonlyArray>, +> = + | { + timeGrain?: undefined; + timeDimension?: Extract>; + } + | { + timeGrain: GrainsForSelectedTimeDims; + timeDimension: Extract>; + }; + +export type UseMetricViewOptions< + K extends MetricKey = MetricKey, + M extends ReadonlyArray> = ReadonlyArray< + InferMeasureKeys + >, + D extends ReadonlyArray> = ReadonlyArray< + InferDimensionKeys + >, +> = { + measures: M; + dimensions?: D; + filter?: MetricFilter; + orderBy?: ReadonlyArray>; + limit?: number; + /** Whether to automatically start the metric query. Default is true. */ + autoStart?: boolean; +} & MetricViewTimeOptions; + +export interface UseMetricViewResult[]> { + data: T | null; + loading: boolean; + error: string | null; + /** Structured upstream error code, mirroring useAnalyticsQuery. */ + errorCode: string | null; + /** Per-column display metadata for the queried columns, carried in the SSE result payload. `undefined` when the server injected no metadata (dormant / unknown key). */ + metadata: Record | undefined; + /** + * Latest warehouse status emitted while waiting for the SQL warehouse to + * reach RUNNING. `null` until the current request receives a status event. + */ + warehouseStatus: WarehouseStatus | null; +} diff --git a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts index 1c63ffe14..93b3dba36 100644 --- a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts +++ b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts @@ -7,6 +7,14 @@ import { useState, } from "react"; import { ArrowClient, connectSSE } from "@/js"; +import { + type AnalyticsSseHandlerContext, + GENERIC_LOAD_ERROR, + getDevMode, + handleAnalyticsSseError, + handleAnalyticsSseMessage, + userFacingFetchError, +} from "./analytics-sse"; import type { AnalyticsFormat, InferParams, @@ -56,112 +64,6 @@ function useStableParams(value: T): T { return ref.current; } -function getDevMode(): string { - const dev = new URL(window.location.href).searchParams.get("dev"); - return dev ? `?dev=${dev}` : ""; -} - -const GENERIC_LOAD_ERROR = "Unable to load data, please try again"; - -/** Map a fetch/SSE transport error to a user-facing message. */ -function userFacingFetchError(error: unknown): string { - if (error instanceof Error) { - if (error.name === "AbortError") { - return "Request timed out, please try again"; - } - if (error.message.includes("Failed to fetch")) { - return "Network error. Please check your connection."; - } - } - return GENERIC_LOAD_ERROR; -} - -interface AnalyticsQuerySseContext { - setLoading: (loading: boolean) => void; - setError: (error: string | null) => void; - setErrorCode: (code: string | null) => void; - setData: (data: ResultType | null) => void; - setWarehouseStatus: (status: WarehouseStatus | null) => void; - publishWarehouseStatus: (status: WarehouseStatus | null) => void; - unpublishWarehouseStatus: () => void; -} - -function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus { - return ( - typeof value === "object" && - value !== null && - typeof (value as WarehouseStatus).state === "string" - ); -} - -async function handleAnalyticsSseMessage( - parsed: Record, - ctx: AnalyticsQuerySseContext, -): Promise { - if (parsed.type === "warehouse_status") { - if (!isWarehouseStatusPayload(parsed.status)) { - ctx.setLoading(false); - ctx.setError(GENERIC_LOAD_ERROR); - ctx.unpublishWarehouseStatus(); - console.error( - "[useAnalyticsQuery] Malformed warehouse_status event", - parsed, - ); - return; - } - ctx.setWarehouseStatus(parsed.status); - ctx.publishWarehouseStatus(parsed.status); - return; - } - - // JSON result. The SSE wire schema is intentionally loose (`data` is an - // optional array of unknown values), so a structural check is enough here — - // no need to ship a schema validator (zod, ~60 KB gz) to the browser just - // to read our own same-origin server's messages. Missing or non-array - // `data` normalizes to [] so `undefined` never bleeds into the hook's - // `T | null` state. - if (parsed.type === "result") { - ctx.setLoading(false); - ctx.setData((Array.isArray(parsed.data) ? parsed.data : []) as ResultType); - ctx.unpublishWarehouseStatus(); - return; - } - - // NOTE: ARROW_STREAM no longer flows over SSE — the server streams the - // raw Arrow IPC bytes back as the query response body, handled by - // `fetchArrowDirect` instead of this SSE handler. - - if (parsed.type === "error" || parsed.error || parsed.code) { - const errorMsg = - (parsed.error as string | undefined) || - (parsed.message as string | undefined) || - "Unable to execute query"; - ctx.setLoading(false); - ctx.setError(errorMsg); - ctx.unpublishWarehouseStatus(); - // Propagate the upstream structured code so UI consumers can branch on - // a stable identifier (e.g. format-switch on - // RESULT_TOO_LARGE_FOR_JSON_FALLBACK or ARROW_DELIVERY_UNSUPPORTED) - // instead of parsing the human-readable message. - if (typeof parsed.errorCode === "string") { - ctx.setErrorCode(parsed.errorCode); - } - if (parsed.code) { - console.error( - `[useAnalyticsQuery] Code: ${parsed.code}, Message: ${errorMsg}`, - ); - } - return; - } - - // Not a warehouse-status, result, or error event — surface a generic error - // rather than silently dropping an unrecognized payload. - console.error("[useAnalyticsQuery] Unrecognized SSE payload", parsed); - ctx.setLoading(false); - ctx.setError(GENERIC_LOAD_ERROR); - ctx.unpublishWarehouseStatus(); -} - interface ArrowDirectContext { url: string; payload: string; @@ -392,13 +294,21 @@ export function useAnalyticsQuery< return; } - const sseContext: AnalyticsQuerySseContext = { + const sseContext: AnalyticsSseHandlerContext = { + source: "useAnalyticsQuery", + resource: { queryKey }, + defaultExecutionError: "Unable to execute query", + unpublishOnMalformedMessage: false, + signal: abortController.signal, + abort: () => abortController.abort(), setLoading, setError, setErrorCode, - setData, - setWarehouseStatus, - publishWarehouseStatus, + onWarehouseStatus: (status) => { + setWarehouseStatus(status); + publishWarehouseStatus(status); + }, + onResult: (message) => setData(message.data as ResultType), unpublishWarehouseStatus, }; @@ -406,44 +316,9 @@ export function useAnalyticsQuery< url: urlSuffix, payload, signal: abortController.signal, - onMessage: async (message) => { - // Drop late envelopes from a stream whose controller was already - // aborted (React StrictMode unmount→remount). Mirrors onError below. - if (abortController.signal.aborted) return; - try { - const parsed = JSON.parse(message.data) as Record; - await handleAnalyticsSseMessage(parsed, sseContext); - } catch (error) { - // A `JSON.parse` failure (or any other thrown error inside the - // SSE message handler) used to leave the hook permanently in - // `loading=true` with no error surfaced — the UI would just - // spin forever. Clear loading and report a user-facing error - // so the consumer can render a retry affordance. - // - // We also abort the SSE connection: if the upstream is - // emitting un-parseable frames, leaving the stream open just - // re-fires the same failure on the next message. Closing - // forces the consumer into a clean retry path. - console.warn("[useAnalyticsQuery] Malformed message received", error); - setLoading(false); - setError(GENERIC_LOAD_ERROR); - abortController.abort(); - } - }, - onError: (error) => { - if (abortController.signal.aborted) return; - setLoading(false); - unpublishWarehouseStatus(); - - if (error instanceof Error) { - console.error("[useAnalyticsQuery] Error", { - queryKey, - error: error.message, - stack: error.stack, - }); - } - setError(userFacingFetchError(error)); - }, + onMessage: (message) => + handleAnalyticsSseMessage(message.data, sseContext), + onError: (error) => handleAnalyticsSseError(error, sseContext), }); }, [ queryKey, diff --git a/packages/appkit-ui/src/react/hooks/use-metric-view.ts b/packages/appkit-ui/src/react/hooks/use-metric-view.ts new file mode 100644 index 000000000..3f95e7193 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -0,0 +1,257 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import type { MetricViewColumnDisplay } from "shared"; +import { connectSSE } from "@/js"; +import { + type AnalyticsSseHandlerContext, + getDevMode, + handleAnalyticsSseError, + handleAnalyticsSseMessage, +} from "./analytics-sse"; +import type { + InferDimensionKeys, + InferMeasureKeys, + MetricKey, + PickMetricRow, + UseMetricViewOptions, + UseMetricViewResult, + WarehouseStatus, +} from "./types"; +import { useAnalyticsWarehousePublisher } from "./use-analytics-warehouse-status"; +import { useQueryHMR } from "./use-query-hmr"; + +function asMetricMetadata( + value: unknown, +): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +/** + * Subscribe to a Unity Catalog Metric View and return its latest result. + * + * @param key - Metric view identifier + * @param options - Measures (required) plus optional dimensions, filter, + * orderBy, timeGrain/timeDimension, and limit + * @returns Metric result state with typed rows, display metadata, and warehouse readiness + * + * @remarks + * `orderBy` and `limit` interact. With `limit`, the route completes the ordering + * with the remaining dimensions so the capped rows are the *same* rows on every + * run — pass `orderBy` to choose WHICH rows (top-N), since the completion only + * makes the result stable, not ranked. Without `limit`, `orderBy` is presentation + * ordering over the full result and gets no completion. + * + * When a request refetches with the same metric key, measures, and dimensions, + * the previous `data` and `metadata` remain available while `loading` is true. + * Changing any of those result-shaping fields clears the previous result. + * + * @example + * ```typescript + * const { data, metadata } = useMetricView("orders", { + * measures: ["revenue"], + * dimensions: ["region"], + * filter: { member: "region", operator: "in", values: ["EMEA", "APAC"] }, + * orderBy: [{ field: "revenue", direction: "DESC" }], + * limit: 10, + * }); + * // JSON_ARRAY preserves SQL scalar cells as strings and nullable columns as null: + * // data: Array<{ revenue: string | null; region: string | null }> | null + * ``` + */ +export function useMetricView< + K extends MetricKey = MetricKey, + const M extends ReadonlyArray> = ReadonlyArray< + InferMeasureKeys + >, + const D extends ReadonlyArray> = readonly [], +>( + key: K, + options: UseMetricViewOptions, +): UseMetricViewResult[]> { + const autoStart = options.autoStart ?? true; + const devMode = getDevMode(); + const urlSuffix = `/api/analytics/metric/${encodeURIComponent(key)}${devMode}`; + + type Rows = PickMetricRow[]; + const [result, setResult] = useState<{ + shape: string; + data: Rows; + metadata: Record | undefined; + } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); + const [warehouseStatus, setWarehouseStatus] = + useState(null); + const abortControllerRef = useRef(null); + const activeRequestKeyRef = useRef(null); + const effectLeaseRef = useRef(0); + const requestedShapeRef = useRef(null); + + const publisherId = useId(); + const { + publish: publishWarehouseStatus, + unpublish: unpublishWarehouseStatus, + } = useAnalyticsWarehousePublisher(publisherId, key); + + if (!key || key.trim().length === 0) { + throw new Error("useMetricView: 'key' must be a non-empty string."); + } + + // Stringify to compare request by value; prevent re-firing on every render. + const payload = useMemo(() => { + const body: { + measures: ReadonlyArray; + dimensions?: ReadonlyArray; + filter?: unknown; + timeGrain?: unknown; + timeDimension?: unknown; + orderBy?: ReadonlyArray; + limit?: number; + } = { measures: options.measures }; + if (options.dimensions !== undefined) body.dimensions = options.dimensions; + if (options.filter !== undefined) body.filter = options.filter; + if (options.timeGrain !== undefined) body.timeGrain = options.timeGrain; + if (options.timeDimension !== undefined) + body.timeDimension = options.timeDimension; + if (options.orderBy !== undefined) body.orderBy = options.orderBy; + if (options.limit !== undefined) body.limit = options.limit; + return JSON.stringify(body); + }, [ + options.measures, + options.dimensions, + options.filter, + options.timeGrain, + options.timeDimension, + options.orderBy, + options.limit, + ]); + + // Detect shape changes: clear rows when metric/measures/dimensions change, + // but keep stale rows during filter/order/limit/time-grain revalidations. + const resultShape = JSON.stringify({ + key, + measures: options.measures, + dimensions: options.dimensions ?? [], + }); + const requestKey = `${urlSuffix}\0${payload}`; + + // Return stale rows only if shape is current; hide during shape transitions. + const isCurrentShape = result?.shape === resultShape; + const data = isCurrentShape ? result.data : null; + const metadata = isCurrentShape ? result.metadata : undefined; + + const start = useCallback(() => { + abortControllerRef.current?.abort(); + + setLoading(true); + setError(null); + setErrorCode(null); + setWarehouseStatus(null); + if (requestedShapeRef.current !== resultShape) { + requestedShapeRef.current = resultShape; + setResult(null); + } + // Register an empty slot to clear stale status from the prior run. + publishWarehouseStatus(null); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + activeRequestKeyRef.current = requestKey; + + const sseContext: AnalyticsSseHandlerContext = { + source: "useMetricView", + resource: { key }, + defaultExecutionError: "Unable to execute metric query", + unpublishOnMalformedMessage: true, + signal: abortController.signal, + abort: () => abortController.abort(), + setLoading, + setError, + setErrorCode, + onWarehouseStatus: (status) => { + setWarehouseStatus(status); + publishWarehouseStatus(status); + }, + onResult: (message) => { + setError(null); + setErrorCode(null); + setResult({ + shape: resultShape, + data: message.data as Rows, + metadata: asMetricMetadata(message.payload.metadata), + }); + }, + unpublishWarehouseStatus, + }; + + connectSSE({ + url: urlSuffix, + payload, + signal: abortController.signal, + onMessage: (message) => + handleAnalyticsSseMessage(message.data, sseContext), + onError: (error) => handleAnalyticsSseError(error, sseContext), + }); + }, [ + key, + payload, + requestKey, + resultShape, + urlSuffix, + publishWarehouseStatus, + unpublishWarehouseStatus, + ]); + + useEffect(() => { + const lease = ++effectLeaseRef.current; + + if (autoStart) { + // React Strict Mode runs an effect's setup/cleanup/setup sequence on + // mount. Keep the still-active identical stream for the second setup so + // development sends one POST, not an immediately-aborted duplicate. + // A real request change still starts synchronously and aborts the prior + // controller in start(). + const activeController = abortControllerRef.current; + if ( + activeRequestKeyRef.current !== requestKey || + !activeController || + activeController.signal.aborted + ) { + start(); + } + } else { + abortControllerRef.current?.abort(); + activeRequestKeyRef.current = null; + unpublishWarehouseStatus(); + } + + return () => { + // Defer teardown by one microtask so Strict Mode's immediate second + // setup can claim the same request. On a genuine unmount there is no new + // lease, so the stream is still cancelled before later async work runs. + const controller = abortControllerRef.current; + queueMicrotask(() => { + if (effectLeaseRef.current !== lease) return; + controller?.abort(); + if (abortControllerRef.current === controller) { + activeRequestKeyRef.current = null; + } + unpublishWarehouseStatus(); + }); + }; + }, [start, autoStart, requestKey, unpublishWarehouseStatus]); + + useQueryHMR(key, start); + + return { data, loading, error, errorCode, metadata, warehouseStatus }; +} diff --git a/packages/appkit-ui/src/react/lib/format.test.ts b/packages/appkit-ui/src/react/lib/format.test.ts new file mode 100644 index 000000000..0c90ba8cd --- /dev/null +++ b/packages/appkit-ui/src/react/lib/format.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { formatFieldLabel } from "./format"; + +describe("formatFieldLabel", () => { + test.each([ + ["totalCost", "Total Cost"], + ["user_name", "User Name"], + ["userID", "User Id"], + ["getHTTPUrl", "Get Http Url"], + ["TOTAL_SPEND", "Total Spend"], + ["", ""], + ['', "Scriptalertxscript"], + ])("formats %j as %j", (field, expected) => { + expect(formatFieldLabel(field)).toBe(expected); + }); +}); diff --git a/packages/appkit-ui/src/react/lib/format.ts b/packages/appkit-ui/src/react/lib/format.ts index 3dceed51f..a87f5901d 100644 --- a/packages/appkit-ui/src/react/lib/format.ts +++ b/packages/appkit-ui/src/react/lib/format.ts @@ -1,3 +1,5 @@ +import { formatLabel } from "../../js/format"; + /** * Formats numeric values based on field name context * @param value - The numeric value to format @@ -46,12 +48,10 @@ export function formatChartValue(value: number, fieldName: string): string { * formatFieldLabel("revenue") // "Revenue" */ export function formatFieldLabel(field: string): string { - const safe = field.replace(/[^a-zA-Z0-9_-]/g, ""); - return safe - .replace(/([A-Z])/g, " $1") - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase()) - .trim(); + // Strip anything outside the identifier charset before humanizing. Table + // callers pass a raw `column.id` / `defaultFilterColumn` that has not been + // through `SAFE_KEY_REGEX`, so the label must not echo arbitrary input. + return formatLabel(field.replace(/[^a-zA-Z0-9_-]/g, "")); } /** diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index a3f14f4af..8ed405a37 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -34,7 +34,9 @@ import { buildMetricSql, composeMetricCacheKey, deriveMetricExecutorKey, + loadMetricMetadata, loadMetricRegistry, + METRIC_METADATA_FILE, selectMetricMetadata, validateMetricRequest, } from "./metric"; @@ -558,18 +560,40 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw err; } + const injectedMetadata = this.config.metricViewsMetadata; + const allMetadata = + injectedMetadata ?? + (await loadMetricMetadata(this.app, req, this.devFileReader)); + + if (allMetadata !== undefined && !Object.hasOwn(allMetadata, key)) { + if (injectedMetadata !== undefined) { + logger.warn( + req, + "No display metadata for metric key %s in the injected metricViewsMetadata", + key, + ); + } else { + logger.warn( + req, + "No display metadata for metric key %s — regenerate types to refresh %s", + key, + METRIC_METADATA_FILE, + ); + } + } + // Computed here, outside the cached execute below, so a cache hit still // serves the current metadata. Absent config → `undefined` → the `result` // message omits the field (envelope-identical to `/query`). const metadata = selectMetricMetadata( - this.config.metricViewsMetadata, + allMetadata, key, request.measures, request.dimensions, ); // Cache key. Composed over the canonicalized args (sorted measures/ - // dimensions, stable-sorted predicates, grain, timeDimension, limit) plus + // dimensions, stable-sorted predicates, grain, timeDimension, limit, orderBy) plus // the `executorKey` — `"sp"` shares the cache across all users, a per-user // identity hash isolates OBO callers. const cacheConfig = { @@ -585,6 +609,7 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { format: "JSON_ARRAY", executorKey, limit: request.limit, + orderBy: request.orderBy, }), }; diff --git a/packages/appkit/src/plugins/analytics/mv/cache.ts b/packages/appkit/src/plugins/analytics/mv/cache.ts index 0eb2ae8c5..a96743f6c 100644 --- a/packages/appkit/src/plugins/analytics/mv/cache.ts +++ b/packages/appkit/src/plugins/analytics/mv/cache.ts @@ -12,23 +12,27 @@ export function composeMetricCacheKey(input: MetricCacheKeyInput): string[] { // `timeDimension` only changes the SQL when `timeGrain` is set (see renderDimensionClause) const timeDimensionPart = input.timeGrain != null ? (input.timeDimension ?? "_") : "_"; + // Preserve `orderBy` sequence: under LIMIT, `a, b` and `b, a` return different rows. + const orderByPart = + input.orderBy !== undefined && input.orderBy.length > 0 + ? JSON.stringify( + input.orderBy.map((o) => [o.field, o.direction ?? "ASC"]), + ) + : "_"; return [ "metric", input.metricKey, input.source, input.format, - // JSON-encode (not raw `.join(",")`): a comma is a legal identifier - // character (`isValidColumnName` rejects only control chars / newlines), so - // joining on `,` would collapse `["a,b"]` and `["a","b"]` to the same key - // element despite rendering different SQL. JSON quoting keeps the key - // one-to-one with the generated SQL — the same encoding `canonicalizeFilter` - // uses for predicate members below. + // Commas are valid in identifiers, so JSON-encode: `["a,b"]` must not + // collide with `["a","b"]`. JSON.stringify(sortedMeasures), JSON.stringify(sortedDimensions), input.timeGrain ?? "_", timeDimensionPart, filterFingerprint, typeof input.limit === "number" ? String(input.limit) : "_", + orderByPart, input.executorKey, ]; } diff --git a/packages/appkit/src/plugins/analytics/mv/constants.ts b/packages/appkit/src/plugins/analytics/mv/constants.ts index f62214d19..4e311bc4e 100644 --- a/packages/appkit/src/plugins/analytics/mv/constants.ts +++ b/packages/appkit/src/plugins/analytics/mv/constants.ts @@ -1,11 +1,63 @@ +import type { MetricFilterOperatorName, MetricOrderDirection } from "shared"; import { METRIC_CONFIG_FILE } from "../../../../../shared/src/schemas/metric-fqn"; -import type { MetricFilterOperatorName, MetricLane } from "../types"; +import type { MetricLane } from "../types"; // Re-exported from the shared zod-free module (single source of truth for the // `definitions.json` basename) so analytics-local callers keep importing it // from this barrel. export { METRIC_CONFIG_FILE }; +/** Runtime vocabulary accepted by the analytics metric-view validator. */ +export const METRIC_FILTER_OPERATORS = [ + "equals", + "notEquals", + "gt", + "gte", + "lt", + "lte", + "contains", + "notContains", + "in", + "notIn", + "set", + "notSet", +] as const satisfies readonly MetricFilterOperatorName[]; + +/** Operators that require at least one value. */ +export const LIST_VALUE_OPERATORS = new Set([ + "in", + "notIn", +]); + +/** Operators that reject `values` entirely. */ +export const NULL_OPERATORS = new Set([ + "set", + "notSet", +]); + +/** Operators that emit `LIKE` / `NOT LIKE` and require a string value. */ +export const STRING_OPERATORS = new Set([ + "contains", + "notContains", +]); + +/** Operators that require exactly one value. */ +export const SINGLE_VALUE_OPERATORS = new Set([ + "equals", + "notEquals", + "gt", + "gte", + "lt", + "lte", + ...STRING_OPERATORS, +]); + +/** Runtime vocabulary accepted by the metric-view `orderBy` validator. */ +export const METRIC_ORDER_DIRECTIONS = [ + "ASC", + "DESC", +] as const satisfies readonly MetricOrderDirection[]; + /** * Measure, dimension, and filter-member names are **column identifiers**: they * are validated by the shared {@link isValidColumnName} (rejects only control @@ -34,6 +86,12 @@ export const METRIC_MEASURES_MAX = 50; export const METRIC_DIMENSIONS_MAX = 20; export const METRIC_FILTER_VALUES_MAX = 1000; export const METRIC_LIMIT_MAX = 100_000; +/** + * Cap on the request's `orderBy` array. The rendered clause can exceed this, + * since a `limit`ed query also appends the unnamed dimensions as tie-breakers — + * the cap bounds what a caller sends, not the emitted key count. + */ +export const METRIC_ORDER_BY_MAX = 20; /** * Maximum number of children per AND/OR group node. @@ -41,35 +99,6 @@ export const METRIC_LIMIT_MAX = 100_000; */ export const METRIC_FILTER_GROUP_MAX = 100; -/** Operators that require at least one value. */ -export const LIST_VALUE_OPERATORS = new Set([ - "in", - "notIn", -]); - -/** Operators that reject `values` entirely. */ -export const NULL_OPERATORS = new Set([ - "set", - "notSet", -]); - -/** Operators that emit `LIKE` / `NOT LIKE` and require a string value. */ -export const STRING_OPERATORS = new Set([ - "contains", - "notContains", -]); - -/** Operators that require exactly one value. */ -export const SINGLE_VALUE_OPERATORS = new Set([ - "equals", - "notEquals", - "gt", - "gte", - "lt", - "lte", - ...STRING_OPERATORS, -]); - /** * Map an entry's declared `executor` to the internal execution lane: * - `"user"` → `"obo"` (per-user cache, on-behalf-of) @@ -80,14 +109,3 @@ export function laneFromExecutor( ): MetricLane { return executor === "user" ? "obo" : "sp"; } - -/** - * The exact twelve filter operators allowed at v1. The runtime tuple is the - * server-side source of truth; the client-side type union - * `MetricFilterOperatorName` mirrors these names statically. - */ -export const METRIC_FILTER_OPERATORS = [ - ...SINGLE_VALUE_OPERATORS, - ...LIST_VALUE_OPERATORS, - ...NULL_OPERATORS, -] as const satisfies readonly MetricFilterOperatorName[]; diff --git a/packages/appkit/src/plugins/analytics/mv/formatters.ts b/packages/appkit/src/plugins/analytics/mv/formatters.ts index 376e01055..c79c7d325 100644 --- a/packages/appkit/src/plugins/analytics/mv/formatters.ts +++ b/packages/appkit/src/plugins/analytics/mv/formatters.ts @@ -62,14 +62,14 @@ export function buildMetricSql( .sort() .map((m) => `MEASURE(${quoteIdentifier(m)}) AS ${quoteIdentifier(m)}`); - const dimensionClauses = [...dimensions] - .sort() - .map((d) => - renderDimensionClause(d, request.timeGrain, request.timeDimension), - ); + const sortedDimensions = [...dimensions].sort(); + const dimensionClauses = sortedDimensions.map((d) => + renderDimensionClause(d, request.timeGrain, request.timeDimension), + ); const selectList = [...measureClauses, ...dimensionClauses].join(", "); const groupByClause = dimensions.length > 0 ? " GROUP BY ALL" : ""; + const orderByClause = renderOrderByClause(request, sortedDimensions); const limitClause = typeof request.limit === "number" && request.limit > 0 @@ -88,7 +88,7 @@ export function buildMetricSql( } } - const statement = `SELECT ${selectList} FROM ${quotedSource}${whereClause}${groupByClause}${limitClause}`; + const statement = `SELECT ${selectList} FROM ${quotedSource}${whereClause}${groupByClause}${orderByClause}${limitClause}`; return { statement, parameters }; } @@ -322,3 +322,44 @@ function renderDimensionClause( } return quoteIdentifier(dim); } + +function renderOrderByClause( + request: IAnalyticsMetricRequest, + sortedDimensions: string[], +): string { + const keyList: string[] = []; + + if (request.orderBy != null && request.orderBy.length > 0) { + for (const entry of request.orderBy) { + if (!isValidColumnName(entry.field)) { + throw new Error( + `Refusing to build SQL: orderBy field "${entry.field}" is not a valid identifier.`, + ); + } + const direction = entry.direction === "DESC" ? " DESC" : ""; + keyList.push(`${quoteIdentifier(entry.field)}${direction}`); + } + } + + // Tie-breaker completion: when limit is set, append all dimensions not + // already named in orderBy. Under GROUP BY ALL the full dimension tuple is + // unique per row, so ordering by all dimensions gives a TOTAL order. A + // partial ordering still leaves ties, and ties + LIMIT = non-determinism. + if (typeof request.limit === "number" && request.limit > 0) { + const orderByFields = new Set(request.orderBy?.map((e) => e.field) ?? []); + for (const dim of sortedDimensions) { + if (!orderByFields.has(dim)) { + keyList.push(quoteIdentifier(dim)); + } + } + } + + // Return empty string when there is nothing to order by. This covers: + // no orderBy + no limit; and no orderBy + limit but zero dimensions + // (a pure aggregate returns exactly one row, ordering is pointless). + if (keyList.length === 0) { + return ""; + } + + return ` ORDER BY ${keyList.join(", ")}`; +} diff --git a/packages/appkit/src/plugins/analytics/mv/index.ts b/packages/appkit/src/plugins/analytics/mv/index.ts index beb12a4b9..ef192c10f 100644 --- a/packages/appkit/src/plugins/analytics/mv/index.ts +++ b/packages/appkit/src/plugins/analytics/mv/index.ts @@ -1,5 +1,9 @@ export { composeMetricCacheKey, deriveMetricExecutorKey } from "./cache"; export { buildMetricSql } from "./formatters"; -export { selectMetricMetadata } from "./metadata"; +export { + loadMetricMetadata, + METRIC_METADATA_FILE, + selectMetricMetadata, +} from "./metadata"; export { loadMetricRegistry } from "./registry"; export { validateMetricRequest } from "./schemas"; diff --git a/packages/appkit/src/plugins/analytics/mv/metadata.ts b/packages/appkit/src/plugins/analytics/mv/metadata.ts index e67302ef8..361d3e096 100644 --- a/packages/appkit/src/plugins/analytics/mv/metadata.ts +++ b/packages/appkit/src/plugins/analytics/mv/metadata.ts @@ -1,18 +1,112 @@ import type { MetricViewColumnDisplay, MetricViewsMetadata } from "shared"; +import { + METRIC_METADATA_BUNDLE_VERSION, + METRIC_METADATA_FILE, + metricMetadataBundleSchema, +} from "../../../../../shared/src/schemas/metric-metadata-bundle"; +import type { AppManager, DevFileReader, RequestLike } from "../../../app"; +import { createLogger } from "../../../logging/logger"; + +const logger = createLogger("analytics:metric-views"); + +export { METRIC_METADATA_FILE }; + +/** + * Parsed-bundle cache, keyed on the raw file contents. + */ +let parsedBundleCache: { raw: string; metadata: MetricViewsMetadata } | null = + null; /** - * Flatten the injected {@link MetricViewsMetadata} for `key` into a single + * The runtime twin of the generated `MetricRegistry` augmentation: the type + * generator emits both from one `DESCRIBE` pass, this side being JSON so the + * plugin can discover it instead of the app importing and injecting it. + * + * Read through {@link AppManager.readMetricViewsConfig} for the same reasons as + * {@link loadMetricRegistry} — dev-tunnel awareness and the traversal guard. + */ +export async function loadMetricMetadata( + app: AppManager, + req?: RequestLike, + devFileReader?: DevFileReader, +): Promise { + let raw: string | null; + try { + raw = await app.readMetricViewsConfig( + METRIC_METADATA_FILE, + req, + devFileReader, + ); + } catch (err) { + logger.warn( + "Failed to read %s: %s", + METRIC_METADATA_FILE, + err instanceof Error ? err.message : String(err), + ); + return undefined; + } + + // Absent file (ENOENT / dev-tunnel not-found) or a rejected traversal path. + // Types were never generated, or generation predates the bundle → dormant. + if (raw === null) { + return undefined; + } + + if (parsedBundleCache?.raw === raw) { + return parsedBundleCache.metadata; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + logger.warn( + "Ignoring malformed %s: %s", + METRIC_METADATA_FILE, + err instanceof Error ? err.message : String(err), + ); + return undefined; + } + + const result = metricMetadataBundleSchema.safeParse(parsed); + if (!result.success) { + logger.warn( + "Ignoring invalid %s: %s", + METRIC_METADATA_FILE, + result.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; "), + ); + return undefined; + } + + if (result.data.version !== METRIC_METADATA_BUNDLE_VERSION) { + logger.warn( + "Ignoring %s written for bundle version %d (this runtime reads version %d) — regenerate types", + METRIC_METADATA_FILE, + result.data.version, + METRIC_METADATA_BUNDLE_VERSION, + ); + return undefined; + } + + // Null-prototype map for the same reason as the registry: a metric key that + // collides with an inherited `Object.prototype` member must not resolve to a + // truthy non-entry at the lookup site in `selectMetricMetadata`. + const metadata: MetricViewsMetadata = Object.create(null); + for (const [key, entry] of Object.entries(result.data.metricViews)) { + metadata[key] = entry; + } + + parsedBundleCache = { raw, metadata }; + return metadata; +} + +/** + * Flatten the resolved {@link MetricViewsMetadata} for `key` into a single * `Record` covering only the requested measures and dimensions, * so the client can label/format just the columns it queried. * - * Pure response decoration: it never touches the cache key or the SQL, and - * reads only from the injected value (never disk / DESCRIBE at runtime). - * - * Lookups go through {@link Object.hasOwn}, so neither an inherited metric key - * nor an inherited column name (`toString`, `__proto__`, …) can resolve to a - * bogus entry. Requested columns absent from the metadata are omitted rather - * than placeheld. - * * Returns `undefined` rather than an empty object when there is nothing to * stamp, so the caller can omit the field and keep the message byte-identical * to a plain `/query` result. diff --git a/packages/appkit/src/plugins/analytics/mv/schemas.ts b/packages/appkit/src/plugins/analytics/mv/schemas.ts index 92fbed0f0..4e8e30f40 100644 --- a/packages/appkit/src/plugins/analytics/mv/schemas.ts +++ b/packages/appkit/src/plugins/analytics/mv/schemas.ts @@ -17,6 +17,8 @@ import { METRIC_FILTER_VALUES_MAX, METRIC_LIMIT_MAX, METRIC_MEASURES_MAX, + METRIC_ORDER_BY_MAX, + METRIC_ORDER_DIRECTIONS, NULL_OPERATORS, SINGLE_VALUE_OPERATORS, STRING_OPERATORS, @@ -120,6 +122,26 @@ const metricRequestSchema = z "timeDimension contains a character that cannot be used in a SQL identifier (control character or newline)", }) .optional(), + orderBy: z + .array( + z + .object({ + field: z + .string() + .min(1, "orderBy field cannot be empty") + .refine(isValidColumnName, { + message: + "orderBy field contains a character that cannot be used in a SQL identifier (control character or newline)", + }), + direction: z.enum(METRIC_ORDER_DIRECTIONS).optional(), + }) + .strict(), + ) + .min(1, "orderBy cannot be an empty array") + .max(METRIC_ORDER_BY_MAX, { + message: `orderBy length exceeds the maximum of ${METRIC_ORDER_BY_MAX}`, + }) + .optional(), limit: z .number() .int({ message: "limit must be an integer" }) @@ -182,6 +204,40 @@ const metricRequestSchema = z path: ["timeDimension"], }); } + + if (value.orderBy != null) { + const selectedNames = new Set([ + ...value.measures, + ...(value.dimensions ?? []), + ]); + + for (let i = 0; i < value.orderBy.length; i++) { + if (!selectedNames.has(value.orderBy[i].field)) { + ctx.addIssue({ + code: "custom", + message: "orderBy field must be one of measures or dimensions", + path: ["orderBy", i, "field"], + }); + } + } + + const seenFields = new Set(); + let hasDuplicate = false; + for (const entry of value.orderBy) { + if (seenFields.has(entry.field)) { + hasDuplicate = true; + break; + } + seenFields.add(entry.field); + } + if (hasDuplicate) { + ctx.addIssue({ + code: "custom", + message: "orderBy fields must be unique", + path: ["orderBy"], + }); + } + } }) as z.ZodType; function validateFilterTree( diff --git a/packages/appkit/src/plugins/analytics/mv/types.ts b/packages/appkit/src/plugins/analytics/mv/types.ts index 791435d17..f7b106d04 100644 --- a/packages/appkit/src/plugins/analytics/mv/types.ts +++ b/packages/appkit/src/plugins/analytics/mv/types.ts @@ -1,4 +1,4 @@ -import type { MetricFilter } from "../types"; +import type { MetricFilter, MetricOrderBy } from "../types"; export interface FilterRenderState { counter: number; @@ -16,4 +16,5 @@ export interface MetricCacheKeyInput { format: string; executorKey: string; limit?: number; + orderBy?: MetricOrderBy[]; } diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts index 5c08b8d43..bb50bbc89 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts @@ -25,12 +25,34 @@ import { analytics } from "../index"; const getAppQuerySpy = vi.spyOn(AppManager.prototype, "getAppQuery"); +/** + * Wait for the supplied server to finish binding, then return the OS-assigned + * port. Required when the test passes `port: 0` to `serverPlugin` — + * `app.server.start()` returns as soon as `listen()` is invoked but before the + * bind completes, so `server.address()` returns `null` until the `listening` + * event fires. + */ +async function getListeningPort(server: Server): Promise { + const addr = server.address(); + if (addr && typeof addr === "object" && typeof addr.port === "number") { + return addr.port; + } + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", (err) => reject(err)); + }); + const ready = server.address(); + if (!ready || typeof ready !== "object") { + throw new Error("Server is listening but address() returned null"); + } + return ready.port; +} + describe("Analytics Plugin Integration", () => { let server: Server; let baseUrl: string; let serviceContextMock: Awaited>; let mockClient: ReturnType; - const TEST_PORT = 9879; beforeAll(async () => { setupDatabricksEnv(); @@ -43,8 +65,11 @@ describe("Analytics Plugin Integration", () => { const app = await createApp({ plugins: [ + // port: 0 → OS assigns an ephemeral port. Avoids EADDRINUSE / cross-test + // route bleed when another integration test (e.g. server.integration) + // holds a fixed port concurrently in the shared vitest worker pool. serverPlugin({ - port: TEST_PORT, + port: 0, host: "127.0.0.1", }), analytics({}), @@ -52,7 +77,8 @@ describe("Analytics Plugin Integration", () => { }); server = app.server.getServer(); - baseUrl = `http://127.0.0.1:${TEST_PORT}`; + const port = await getListeningPort(server); + baseUrl = `http://127.0.0.1:${port}`; }); afterAll(async () => { diff --git a/packages/appkit/src/plugins/analytics/tests/metric.test.ts b/packages/appkit/src/plugins/analytics/tests/metric.test.ts index e704ae309..eedbac776 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -18,6 +18,7 @@ import { buildMetricSql, composeMetricCacheKey, deriveMetricExecutorKey, + loadMetricMetadata, loadMetricRegistry, selectMetricMetadata, validateMetricRequest, @@ -345,14 +346,14 @@ describe("analytics metric route", () => { ); }); - test("dimensions + limit compose GROUP BY ALL then LIMIT", () => { + test("dimensions + limit compose GROUP BY ALL then ORDER BY (deterministic) then LIMIT", () => { const { statement } = buildMetricSql(registration, { measures: ["arr"], dimensions: ["region"], limit: 100, }); expect(statement).toBe( - "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL LIMIT 100", + "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `region` LIMIT 100", ); }); @@ -462,6 +463,235 @@ describe("analytics metric route", () => { }); }); + // ── orderBy identifier safety. `renderOrderByClause` re-gates each field + // rather than trusting the validator, because `buildMetricSql` is exported and + // reachable on paths that never ran the request schema. + describe("buildMetricSql orderBy identifier safety (quoting)", () => { + const registration: MetricRegistration = { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }; + + test("neutralizes an injection-shaped orderBy field by quoting", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr; DROP TABLE users"], + orderBy: [{ field: "arr; DROP TABLE users" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr; DROP TABLE users`) AS `arr; DROP TABLE users` FROM `cat`.`sch`.`revenue_metrics` ORDER BY `arr; DROP TABLE users`", + ); + }); + + test("neutralizes a backtick in an orderBy field by doubling it", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr`"], + orderBy: [{ field: "arr`", direction: "DESC" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr```) AS `arr``` FROM `cat`.`sch`.`revenue_metrics` ORDER BY `arr``` DESC", + ); + }); + + test("throws for an orderBy field containing a control character", () => { + expect(() => + buildMetricSql(registration, { + measures: ["arr"], + orderBy: [{ field: "arr\tbad" }], + }), + ).toThrow(/not a valid identifier|control character/); + }); + }); + + // ── orderBy clause: explicit ordering, direction normalization, deterministic + // default completion, and cache-key semantics. The orderBy feature adds + // `ORDER BY ` between `GROUP BY ALL` and `LIMIT`. + describe("buildMetricSql orderBy (explicit ordering)", () => { + const registration: MetricRegistration = { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }; + + test("single explicit orderBy key without limit → ORDER BY (no tie-breaker)", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `region`", + ); + }); + + test("orderBy direction: DESC → renders ` DESC` suffix", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region", direction: "DESC" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `region` DESC", + ); + }); + + test("orderBy direction: ASC explicitly → renders WITHOUT ASC keyword (SQL default)", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region", direction: "ASC" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `region`", + ); + }); + + test("multi-key orderBy preserves caller order (not sorted)", () => { + const { statement: stmt1 } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region", "segment"], + orderBy: [{ field: "region" }, { field: "segment" }], + }); + expect(stmt1).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region`, `segment` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `region`, `segment`", + ); + + // Reversed order produces different SQL (proves entries are not sorted). + const { statement: stmt2 } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region", "segment"], + orderBy: [{ field: "segment" }, { field: "region" }], + }); + expect(stmt2).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region`, `segment` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `segment`, `region`", + ); + expect(stmt1).not.toBe(stmt2); + }); + + test("orderBy can name a measure", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr", "revenue"], + dimensions: ["region"], + orderBy: [{ field: "revenue" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, MEASURE(`revenue`) AS `revenue`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `revenue`", + ); + }); + + test("orderBy can name a dimension", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["order_date", "region"], + orderBy: [{ field: "order_date" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `order_date`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `order_date`", + ); + }); + + test("orderBy naming the timeDimension with timeGrain → ORDER BY bare column (no date_trunc in clause)", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["order_date", "region"], + timeGrain: "month", + timeDimension: "order_date", + orderBy: [{ field: "order_date" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, date_trunc('month', `order_date`) AS `order_date`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `order_date`", + ); + }); + }); + + // ── orderBy deterministic default (tie-breaker completion): when limit is + // set and no orderBy is provided, the builder appends ALL dimensions in + // sorted order as a tie-breaker to ensure deterministic results under LIMIT. + describe("buildMetricSql orderBy deterministic default (tie-breaker completion)", () => { + const registration: MetricRegistration = { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }; + + test("limit set + no orderBy → appends all dimensions in sorted order", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region", "segment"], + limit: 100, + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region`, `segment` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `region`, `segment` LIMIT 100", + ); + }); + + test("limit set + no orderBy + two dimensions → tie-breaker preserves sorted order", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["zebra", "apple"], + limit: 100, + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `apple`, `zebra` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `apple`, `zebra` LIMIT 100", + ); + }); + + test("limit set + no orderBy + no dimensions → no ORDER BY clause (pure aggregate)", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + limit: 100, + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr` FROM `cat`.`sch`.`revenue_metrics` LIMIT 100", + ); + }); + + test("no limit + dimensions + no orderBy → no ORDER BY clause", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region", "segment"], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region`, `segment` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL", + ); + }); + + test("limit set + explicit orderBy naming one of two dimensions → explicit key first, then unnamed dimension appended", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region", "segment"], + limit: 100, + orderBy: [{ field: "segment" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region`, `segment` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `segment`, `region` LIMIT 100", + ); + }); + + test("limit set + explicit orderBy naming a measure → measure first, all dimensions appended in sorted order", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr", "revenue"], + dimensions: ["region", "segment"], + limit: 100, + orderBy: [{ field: "revenue" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, MEASURE(`revenue`) AS `revenue`, `region`, `segment` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `revenue`, `region`, `segment` LIMIT 100", + ); + }); + + test("explicit orderBy + no limit → honored WITHOUT tie-breaker completion", () => { + const { statement } = buildMetricSql(registration, { + measures: ["arr"], + dimensions: ["region", "segment"], + orderBy: [{ field: "segment" }], + }); + expect(statement).toBe( + "SELECT MEASURE(`arr`) AS `arr`, `region`, `segment` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL ORDER BY `segment`", + ); + }); + }); + // ── Envelope parity — streams warehouse_status* then a `result` message, // the same event shape as the /query route's JSON SSE path. describe("_handleMetricRoute SSE envelope", () => { @@ -655,6 +885,95 @@ describe("analytics metric route", () => { }); }); + // ── The "no metadata for this key" warning names a remedy, so it must know + // which source it is talking about: regenerating types cannot fix an + // injected value, and the bundle is not read at all on that path. + test("a key missing from the injected metadata does not advise regenerating types", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const plugin = pluginForDir( + // Injected metadata that covers `revenue` but not `costs`. + { ...config, metricViewsMetadata: REVENUE_METADATA }, + registryDir({ + costs: { key: "costs", source: "cat.sch.cost_metrics", lane: "sp" }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ spend: 1 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + await handler( + createMockRequest({ + params: { key: "costs" }, + body: { measures: ["spend"] }, + }), + createMockResponse(), + ); + + const warnings = warnSpy.mock.calls.map((c) => c.join(" ")); + const missing = warnings.filter((w) => + w.includes("No display metadata for metric key"), + ); + expect(missing.length).toBeGreaterThan(0); + expect(missing.join(" ")).toContain("injected metricViewsMetadata"); + // The generated bundle is never consulted on the injected path, so + // naming it here would send the operator after the wrong file. + expect(missing.join(" ")).not.toContain("regenerate types"); + expect(missing.join(" ")).not.toContain("metadata.generated.json"); + warnSpy.mockRestore(); + }); + + test("a key missing from the discovered bundle advises regenerating types", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const dir = registryDir({ + costs: { key: "costs", source: "cat.sch.cost_metrics", lane: "sp" }, + }); + // A bundle must exist for this branch to be reachable: an absent bundle + // resolves to `undefined`, which is dormancy rather than a stale bundle. + // It covers `revenue` but not the `costs` key being queried. + writeFileSync( + path.join(dir, "metadata.generated.json"), + JSON.stringify({ + version: 1, + metricViews: { + revenue: { + measures: { arr: { type: "double" } }, + dimensions: {}, + }, + }, + }), + ); + const plugin = pluginForDir(config, dir); // no injection → discovery path + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ spend: 1 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + await handler( + createMockRequest({ + params: { key: "costs" }, + body: { measures: ["spend"] }, + }), + createMockResponse(), + ); + + const warnings = warnSpy.mock.calls.map((c) => c.join(" ")); + const missing = warnings.filter((w) => + w.includes("No display metadata for metric key"), + ); + expect(missing.length).toBeGreaterThan(0); + // Regenerating types is the correct remedy here, and it names the file to + // regenerate — the opposite of the injected path above. + expect(missing.join(" ")).toContain("regenerate types"); + expect(missing.join(" ")).toContain("metadata.generated.json"); + expect(missing.join(" ")).not.toContain("injected metricViewsMetadata"); + warnSpy.mockRestore(); + }); + test("omits the metadata field entirely when no metadata is injected (envelope parity with /query)", async () => { const plugin = pluginForDir( config, // no metricViewsMetadata @@ -1130,6 +1449,111 @@ describe("loadMetricRegistry", () => { await expect(loadMetricRegistry(app)).rejects.toThrow(/Failed to parse/); }); + // ── The generated metadata bundle. Unlike definitions.json, every failure + // mode here degrades to `undefined` instead of throwing: metadata is pure + // response decoration, so a bad bundle must never fail a working query. + describe("loadMetricMetadata", () => { + const bundle = (extra?: Record) => + JSON.stringify({ + version: 1, + metricViews: { + revenue: { + measures: { arr: { type: "double", format: "$#,##0.00" } }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }, + }, + ...extra, + }); + + test("absent bundle → undefined (dormancy)", async () => { + expect(await loadMetricMetadata(app)).toBeUndefined(); + }); + + test("reads per-column metadata for a valid bundle", async () => { + writeFileSync(path.join(dir, "metadata.generated.json"), bundle()); + const metadata = await loadMetricMetadata(app); + expect(metadata?.revenue).toEqual({ + measures: { arr: { type: "double", format: "$#,##0.00" } }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }); + }); + + test("result has a null prototype (no inherited-property lookups)", async () => { + writeFileSync(path.join(dir, "metadata.generated.json"), bundle()); + const metadata = await loadMetricMetadata(app); + expect(Object.getPrototypeOf(metadata)).toBeNull(); + expect( + (metadata as unknown as Record).toString, + ).toBeUndefined(); + }); + + test("malformed JSON → undefined, never throws", async () => { + writeFileSync(path.join(dir, "metadata.generated.json"), "{ not json"); + await expect(loadMetricMetadata(app)).resolves.toBeUndefined(); + }); + + test("schema-invalid bundle → undefined, never throws", async () => { + writeFileSync( + path.join(dir, "metadata.generated.json"), + JSON.stringify({ version: 1, metricViews: { revenue: "nope" } }), + ); + await expect(loadMetricMetadata(app)).resolves.toBeUndefined(); + }); + + test("a future bundle version is ignored rather than mis-parsed", async () => { + writeFileSync( + path.join(dir, "metadata.generated.json"), + JSON.stringify({ version: 99, metricViews: {} }), + ); + await expect(loadMetricMetadata(app)).resolves.toBeUndefined(); + }); + + test("tolerates unknown per-column fields from a newer generator", async () => { + // Non-strict per-column schema: an older runtime keeps serving the fields + // it understands instead of rejecting the whole bundle. + writeFileSync( + path.join(dir, "metadata.generated.json"), + JSON.stringify({ + version: 1, + metricViews: { + revenue: { + measures: { arr: { type: "double", unit_of_measure: "USD" } }, + dimensions: {}, + }, + }, + }), + ); + const metadata = await loadMetricMetadata(app); + expect(metadata?.revenue.measures.arr.type).toBe("double"); + }); + + test("picks up an edited bundle rather than serving a stale parse", async () => { + const file = path.join(dir, "metadata.generated.json"); + writeFileSync(file, bundle()); + expect((await loadMetricMetadata(app))?.revenue.measures.arr.format).toBe( + "$#,##0.00", + ); + + // The parse cache is keyed on raw contents, so a regenerated bundle is + // reflected without a restart (matters for the dev-tunnel path). + writeFileSync( + file, + JSON.stringify({ + version: 1, + metricViews: { + revenue: { + measures: { arr: { type: "double", format: "€#,##0" } }, + dimensions: {}, + }, + }, + }), + ); + expect((await loadMetricMetadata(app))?.revenue.measures.arr.format).toBe( + "€#,##0", + ); + }); + }); + test("schema-invalid config throws", async () => { writeFileSync( path.join(dir, "definitions.json"), @@ -1891,6 +2315,143 @@ describe("metric — filter translator", () => { }); }); + describe("orderBy (validator)", () => { + test("valid orderBy with one dimension is accepted", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region" }], + }), + ).not.toThrow(); + }); + + test("valid orderBy with direction: ASC is accepted", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region", direction: "ASC" }], + }), + ).not.toThrow(); + }); + + test("valid orderBy with direction: DESC is accepted", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region", direction: "DESC" }], + }), + ).not.toThrow(); + }); + + test("valid orderBy round-trips unchanged", () => { + const req = validateMetricRequest({ + measures: ["arr"], + dimensions: ["region", "segment"], + orderBy: [{ field: "segment", direction: "DESC" }, { field: "region" }], + }); + expect(req.orderBy).toEqual([ + { field: "segment", direction: "DESC" }, + { field: "region" }, + ]); + }); + + test("orderBy field must be in measures or dimensions", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "unknown_field" }], + }), + ).toThrowError(/fields:.*orderBy.*0.*field/); + }); + + test("orderBy field can be a measure", () => { + expect(() => + validateMetricRequest({ + measures: ["arr", "revenue"], + dimensions: ["region"], + orderBy: [{ field: "revenue" }], + }), + ).not.toThrow(); + }); + + test("orderBy rejects an unknown direction value", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region", direction: "UP" as never }], + }), + ).toThrowError(/fields:.*orderBy/); + }); + + test("orderBy entry rejects extra properties (strict mode)", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region", extra: "property" } as never], + }), + ).toThrowError(/fields:.*orderBy/); + }); + + test("orderBy rejects duplicate fields", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region", "segment"], + orderBy: [{ field: "region" }, { field: "region" }], + }), + ).toThrowError(/fields:.*orderBy/); + }); + + test("orderBy rejects empty array", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [], + }), + ).toThrowError(/fields:.*orderBy/); + }); + + test("orderBy rejects over-cap (21+ entries)", () => { + const orderByArray = Array.from({ length: 21 }, (_, i) => ({ + field: `dim_${i}`, + })); + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: orderByArray.map((_, i) => `dim_${i}`), + orderBy: orderByArray as never, + }), + ).toThrowError(/fields:.*orderBy/); + }); + + test("orderBy field with control character is rejected", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ field: "region\tbad" }], + }), + ).toThrowError(/fields:.*orderBy/); + }); + + test("orderBy field missing entirely is rejected (Zod type error)", () => { + expect(() => + validateMetricRequest({ + measures: ["arr"], + dimensions: ["region"], + orderBy: [{ direction: "DESC" } as never], + }), + ).toThrowError(); + }); + }); + describe("sort-before-hash (predicate ordering inside groups)", () => { test("predicate order does not affect the rendered SQL within an AND group", () => { const a = render({ @@ -2231,6 +2792,78 @@ describe("composeMetricCacheKey", () => { }); expect(sp).not.toEqual(obo); }); + + test("different orderBy → different keys", () => { + const a = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + orderBy: [{ field: "region" }], + }); + const b = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + orderBy: [{ field: "region", direction: "DESC" }], + }); + expect(a).not.toEqual(b); + }); + + test("orderBy field ORDER matters — sequence is semantic, not sorted (prevents cache collision)", () => { + // ORDER BY a, b returns different rows than ORDER BY b, a under LIMIT. + // orderBy is NOT sorted before hashing (unlike measures/dimensions), so the + // sequence must fork the key. This test guards against a regression. + const ab = composeMetricCacheKey({ + ...base, + dimensions: ["a", "b"], + orderBy: [{ field: "a" }, { field: "b" }], + }); + const ba = composeMetricCacheKey({ + ...base, + dimensions: ["a", "b"], + orderBy: [{ field: "b" }, { field: "a" }], + }); + expect(ab).not.toEqual(ba); + }); + + test("orderBy direction normalization: absent vs ASC → same key", () => { + const noDir = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + orderBy: [{ field: "region" }], + }); + const asc = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + orderBy: [{ field: "region", direction: "ASC" }], + }); + expect(noDir).toEqual(asc); + }); + + test("orderBy direction: DESC → different key from absent/ASC", () => { + const noDir = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + orderBy: [{ field: "region" }], + }); + const desc = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + orderBy: [{ field: "region", direction: "DESC" }], + }); + expect(noDir).not.toEqual(desc); + }); + + test("absent vs present orderBy → different keys", () => { + const without = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + }); + const with_ = composeMetricCacheKey({ + ...base, + dimensions: ["region"], + orderBy: [{ field: "region" }], + }); + expect(without).not.toEqual(with_); + }); }); // ── executor-key isolation. The key is what scopes the cache — `"sp"` diff --git a/packages/appkit/src/plugins/analytics/tests/types.test.ts b/packages/appkit/src/plugins/analytics/tests/types.test.ts new file mode 100644 index 000000000..fc116b288 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/tests/types.test.ts @@ -0,0 +1,33 @@ +import type { + MetricFilter as SharedMetricFilter, + MetricFilterOperatorName as SharedMetricFilterOperatorName, + MetricOrderDirection as SharedMetricOrderDirection, + MetricPredicate as SharedMetricPredicate, +} from "shared"; +import { describe, expectTypeOf, test } from "vitest"; +import type { + METRIC_FILTER_OPERATORS, + METRIC_ORDER_DIRECTIONS, +} from "../mv/constants"; +import type { + MetricFilter, + MetricFilterOperatorName, + MetricPredicate, +} from "../types"; + +describe("analytics metric-filter types", () => { + test("re-exports the shared AST types", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("keeps runtime vocabularies in exact parity with the shared contract", () => { + expectTypeOf().toEqualTypeOf< + (typeof METRIC_FILTER_OPERATORS)[number] + >(); + expectTypeOf().toEqualTypeOf< + (typeof METRIC_ORDER_DIRECTIONS)[number] + >(); + }); +}); diff --git a/packages/appkit/src/plugins/analytics/types.ts b/packages/appkit/src/plugins/analytics/types.ts index c070740d6..924373f07 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -1,15 +1,23 @@ import type { BasePluginConfig, + MetricFilter, + MetricOrderBy, MetricViewColumnDisplay, MetricViewsMetadata, } from "shared"; +export type { + MetricFilter, + MetricFilterOperatorName, + MetricOrderBy, + MetricPredicate, +} from "shared"; + export interface IAnalyticsConfig extends BasePluginConfig { timeout?: number; /** - * Build-generated per-metric column metadata, keyed by metric key. The - * metric route scopes this to the requested measures and dimensions before - * attaching it to the SSE result. + * Per-column display metadata. The metric route stamps the slice scoped to a + * request's measures/dimensions into the SSE `result` message. */ metricViewsMetadata?: MetricViewsMetadata; /** @@ -23,22 +31,20 @@ export interface IAnalyticsConfig extends BasePluginConfig { * controlled deployments where billable warehouse starts must not be * triggered by user requests; in that case `STOPPED` surfaces as a * `ConfigurationError`. + * + * @default true */ autoStartWarehouse?: boolean; /** * Fail-fast ceiling (ms) for an `ARROW_STREAM` query to produce its first * byte (warehouse readiness + execute + first chunk). Past this, a stuck or * overloaded warehouse returns a `503` (`WAREHOUSE_UNAVAILABLE`) instead of - * hanging until the client disconnects. Defaults to 2 min. Once the first - * byte arrives the stream is not time-bounded. + * hanging until the client disconnects. + * Defaults to 2 min. */ arrowFirstByteTimeoutMs?: number; } -/** - * SQL warehouse lifecycle states surfaced by the analytics route. - * Mirrors the states emitted by the Databricks SQL SDK (`sql.State`). - */ export type WarehouseState = | "RUNNING" | "STARTING" @@ -64,10 +70,9 @@ export interface WarehouseStatus { } /** - * Discriminated union of every SSE message shape emitted by - * `POST /api/analytics/query/:query_key`. Useful for typing the client-side - * `onMessage` handler (and is the source of truth re-mirrored in - * `appkit-ui` since that package can't depend on `appkit`). + * Discriminated union of every SSE message shape emitted by the analytics + * routes (`POST /api/analytics/query/:query_key` and + * `POST /api/analytics/metric/:key`). */ export type AnalyticsStreamMessage = | { type: "warehouse_status"; status: WarehouseStatus } @@ -83,7 +88,7 @@ export type AnalyticsStreamMessage = statement_id: string; status: { state: string }; } - | { type: "error"; error: string; code?: string }; + | { type: "error"; error: string; code?: string; errorCode?: string }; /** * Supported response formats for analytics queries. @@ -139,7 +144,7 @@ export interface AnalyticsQueryResponse { * - `"sp"` ← `executor: "app_service_principal"` — queried as the app * service principal (cache shared across all users). * - `"obo"` ← `executor: "user"` — queried on-behalf-of the requesting - * user (per-user cache). OBO dispatch is wired in a later phase. + * user (per-user cache) via `asUser(req)`. */ export type MetricLane = "sp" | "obo"; @@ -158,48 +163,6 @@ export interface MetricRegistration { lane: MetricLane; } -/** - * v1 filter operator vocabulary — exactly twelve names. The runtime tuple - * `METRIC_FILTER_OPERATORS` (next to the validator in `metric.ts`) is the - * server-side source of truth; this union mirrors it statically. - */ -export type MetricFilterOperatorName = - | "equals" - | "notEquals" - | "in" - | "notIn" - | "gt" - | "gte" - | "lt" - | "lte" - | "contains" - | "notContains" - | "set" - | "notSet"; - -/** - * A single filter predicate — the leaf node of the recursive - * {@link MetricFilter} tree. `member` is a dimension name (grammar-gated, not - * allowlisted); `values` is bound through parameterized `:f_` bind vars - * and never interpolated into the SQL string. - */ -export interface MetricPredicate { - member: string; - operator: MetricFilterOperatorName; - values?: ReadonlyArray; -} - -/** - * Recursive filter expression for the metric-view request body: a leaf - * {@link MetricPredicate} or an `{ and: [...] }` / `{ or: [...] }` group. The - * shape is intentionally non-generic server-side — per-metric narrowing (if - * any) lives client-side. - */ -export type MetricFilter = - | MetricPredicate - | { and: ReadonlyArray } - | { or: ReadonlyArray }; - /** * Validated request body for `POST /api/analytics/metric/:key`. * @@ -208,7 +171,9 @@ export type MetricFilter = * clause. `timeGrain` buckets the single dimension named by `timeDimension` * via `date_trunc`; it requires `timeDimension`, and `timeDimension` must be * one of `dimensions` so it is selected and in `GROUP BY ALL`. Both tokens are - * grammar-gated before they reach SQL. + * grammar-gated before they reach SQL. `orderBy` controls row ordering; when + * `limit` is set, any dimensions not named in `orderBy` are appended as + * tie-breakers so the ordering is total and `LIMIT` is deterministic. */ export interface IAnalyticsMetricRequest { measures: string[]; @@ -221,6 +186,7 @@ export interface IAnalyticsMetricRequest { * required whenever `timeGrain` is set. Grammar-gated as a SQL identifier. */ timeDimension?: string; + orderBy?: MetricOrderBy[]; limit?: number; format?: AnalyticsFormat; } diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 45cfce711..bbbd7c525 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import dotenv from "dotenv"; import pc from "picocolors"; +import { METRIC_METADATA_FILE } from "../../../shared/src/schemas/metric-metadata-bundle"; import { createLogger } from "../logging/logger"; import { createWorkspaceClient, @@ -28,7 +29,10 @@ import { } from "./migration"; import { readMetricConfig, resolveMetricConfig } from "./mv-registry/config"; import { createWorkspaceDescribeFetcher } from "./mv-registry/describe"; -import { generateMetricTypeDeclarations } from "./mv-registry/render-types"; +import { + buildMetricMetadataBundle, + generateMetricTypeDeclarations, +} from "./mv-registry/render-types"; import { emptyMetricSchema, syncMetrics } from "./mv-registry/sync"; import type { DescribeFetcher, @@ -834,7 +838,7 @@ export async function syncMetricViewsTypes(options: { // Same anti-clobber rule as the query path: when suppressDegradedWrite is set // (blocking mode), skip the write if any metric degraded, preserving the - // committed metric-views.ts. Non-blocking mode always writes. + // committed metric-views.d.ts. Non-blocking mode always writes. const shouldWriteMetrics = !suppressDegradedWrite || !hasAnyDegradedMetrics(schemas); @@ -845,20 +849,24 @@ export async function syncMetricViewsTypes(options: { generateMetricTypeDeclarations(schemas), "utf-8", ); + + const bundlePath = path.join(metricViewsFolder, METRIC_METADATA_FILE); + await fs.writeFile( + bundlePath, + `${JSON.stringify(buildMetricMetadataBundle(schemas), null, 2)}\n`, + "utf-8", + ); + logger.debug("Wrote metric metadata bundle to %s", bundlePath); } - // Sweep the ambient `metric-views.d.ts` a pre-`.ts` version left behind, - // which would otherwise duplicate the augmentation the new `.ts` emits. - // Skipped unless the replacement was actually written, so a degraded - // blocking pass leaves an app's only committed metric types in place. - if ( - metricOutFile.endsWith(".ts") && - !metricOutFile.endsWith(".d.ts") && - existsSync(metricOutFile) - ) { - const staleDts = `${metricOutFile.slice(0, -".ts".length)}.d.ts`; + // Sweep the `metric-views.ts` an interim version left behind, which would + // otherwise duplicate the augmentation the `.d.ts` emits. Skipped unless the + // replacement was actually written, so a degraded blocking pass leaves an + // app's only committed metric types in place. + if (metricOutFile.endsWith(".d.ts") && existsSync(metricOutFile)) { + const staleTs = `${metricOutFile.slice(0, -".d.ts".length)}.ts`; try { - await fs.unlink(staleDts); - logger.debug("Removed stale generated types at %s", staleDts); + await fs.unlink(staleTs); + logger.debug("Removed stale generated types at %s", staleTs); } catch { // No stale sibling — nothing to clean up. } @@ -904,4 +912,4 @@ export type { export const TYPES_DIR = "appkit-types"; export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; -export const METRIC_TYPES_FILE = "metric-views.ts"; +export const METRIC_TYPES_FILE = "metric-views.d.ts"; diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index ff342dd2f..ce7be4e18 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -1,35 +1,16 @@ +import type { MetricViewColumnDisplay } from "../../../../shared/src/metric-metadata"; +import { + METRIC_METADATA_BUNDLE_VERSION, + type MetricMetadataBundle, +} from "../../../../shared/src/schemas/metric-metadata-bundle"; import type { MetricColumnMetadata, MetricSchema } from "./types"; /** - * @todo unify with query-registry.ts - * Map a Databricks SQL type to a TypeScript primitive. - * Centralized here (not imported from query-registry) so this module - * stays self-contained. + * Metric results use Databricks' JSON_ARRAY delivery, whose scalar cells are + * strings regardless of their SQL type. Every selected column can also be SQL + * NULL. */ -function tsTypeFor(sqlType: string): string { - const normalized = sqlType - .toUpperCase() - .replace(/\(.*\)$/, "") - .replace(/<.*>$/, "") - .split(" ")[0]; - - switch (normalized) { - case "BOOLEAN": - return "boolean"; - case "TINYINT": - case "SMALLINT": - case "INT": - case "INTEGER": - case "BIGINT": - case "FLOAT": - case "DOUBLE": - case "DECIMAL": - case "NUMERIC": - return "number"; - default: - return "string"; - } -} +const JSON_ARRAY_WIRE_TYPE = "string | null"; // Render a MetricRegistry interface entry from a MetricSchema. function renderMetricEntry(schema: MetricSchema): string { @@ -45,7 +26,7 @@ function renderMetricEntry(schema: MetricSchema): string { ? ` @timeGrain ${col.timeGrains.join("|")}` : ""; return `${indent}/** @sqlType ${col.type.replace(/\*\//g, "* /")}${grainComment} */ -${indent}${JSON.stringify(col.name)}: ${tsTypeFor(col.type)}`; +${indent}${JSON.stringify(col.name)}: ${JSON_ARRAY_WIRE_TYPE}`; }) .join(";\n"); return `{ @@ -170,52 +151,42 @@ ${inner}; }`; } -// Value-side twin of a `renderMetadataMap` entry, minus `time_grain` (not part -// of MetricViewColumnDisplay). -function renderMetadataValueField(col: MetricColumnMetadata): string { - const fields = metadataFields(col).map( - ([name, value]) => `${name}: ${value}`, - ); - return `{ ${fields.join(", ")} }`; +// Value-side twin of a `renderMetadataMap` entry, minus `time_grain` (which is +// type-only and not part of MetricViewColumnDisplay). +function metadataValue(col: MetricColumnMetadata): MetricViewColumnDisplay { + const value: MetricViewColumnDisplay = { type: col.type }; + if (col.displayName) value.display_name = col.displayName; + if (col.format) value.format = col.format; + if (col.description) value.description = col.description; + return value; } -// Render one metric's runtime measures/dimensions map, keyed by column name. -function renderMetadataValueMap( +function metadataValueMap( cols: MetricColumnMetadata[], - indent: string, -): string { - if (cols.length === 0) return "{}"; - const inner = cols - .map( - (col) => - `${indent} ${JSON.stringify(col.name)}: ${renderMetadataValueField(col)}`, - ) - .join(",\n"); - return `{ -${inner}, -${indent}}`; +): Record { + const map: Record = {}; + for (const col of cols) { + map[col.name] = metadataValue(col); + } + return map; } -// Render the runtime `metricViewsMetadata` const, emitted `as const` in the -// same key order as the augmentation. -function renderMetricViewsMetadata(schemas: MetricSchema[]): string { - if (schemas.length === 0) { - return "export const metricViewsMetadata = {} as const;\n"; +/** + * Entries keep the same key order as the augmentation. Degraded schemas + * contribute empty maps — the warehouse could not describe their columns, so + * there is no display metadata to stamp. + */ +export function buildMetricMetadataBundle( + schemas: MetricSchema[], +): MetricMetadataBundle { + const metricViews: MetricMetadataBundle["metricViews"] = {}; + for (const schema of schemas) { + metricViews[schema.key] = { + measures: metadataValueMap(schema.measures), + dimensions: metadataValueMap(schema.dimensions), + }; } - const entries = schemas - .map((schema) => { - const measures = renderMetadataValueMap(schema.measures, " "); - const dimensions = renderMetadataValueMap(schema.dimensions, " "); - return ` ${JSON.stringify(schema.key)}: { - measures: ${measures}, - dimensions: ${dimensions}, - }`; - }) - .join(",\n"); - return `export const metricViewsMetadata = { -${entries}, -} as const; -`; + return { version: METRIC_METADATA_BUNDLE_VERSION, metricViews }; } // Render the augmentation block for the appkit-ui MetricRegistry interface. @@ -235,20 +206,11 @@ ${entries}; `; } -/** - * Build the full metric-views.ts file from a list of metric schemas. - * - * The header must stay a type-only `import type {} from`: it anchors the module - * so the augmentation resolves while compiling to zero runtime code, whereas a - * bare `import "@databricks/appkit-ui/react"` would execute the client package - * entry on the Node server. - */ export function generateMetricTypeDeclarations( schemas: MetricSchema[], ): string { return `// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import type {} from "@databricks/appkit-ui/react"; -${renderMetricRegistry(schemas)} -${renderMetricViewsMetadata(schemas)}`; +import "@databricks/appkit-ui/react"; +${renderMetricRegistry(schemas)}`; } diff --git a/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap b/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap index 7d1992ec9..4ea2eb5c7 100644 --- a/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap +++ b/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap @@ -3,7 +3,7 @@ exports[`generateMetricTypeDeclarations — snapshot > emits TimeGrain union for a metric view with time-typed + regular dimensions 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import type {} from "@databricks/appkit-ui/react"; +import "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { "revenue": { @@ -12,15 +12,15 @@ declare module "@databricks/appkit-ui/react" { lane: "sp"; measures: { /** @sqlType DECIMAL(38,2) */ - "arr": number; + "arr": string | null; }; dimensions: { /** @sqlType TIMESTAMP @timeGrain day|hour|minute|month|quarter|week|year */ - "created_at": string; + "created_at": string | null; /** @sqlType STRING */ - "region": string; + "region": string | null; /** @sqlType STRING */ - "segment": string; + "segment": string | null; }; measureKeys: "arr"; dimensionKeys: "created_at" | "region" | "segment"; @@ -48,26 +48,13 @@ declare module "@databricks/appkit-ui/react" { }; } } - -export const metricViewsMetadata = { - "revenue": { - measures: { - "arr": { type: "DECIMAL(38,2)", description: "Annual recurring revenue" }, - }, - dimensions: { - "created_at": { type: "TIMESTAMP" }, - "region": { type: "STRING" }, - "segment": { type: "STRING" }, - }, - }, -} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits a stable MetricRegistry augmentation for a mixed sp + obo input 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import type {} from "@databricks/appkit-ui/react"; +import "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { "customer_metrics": { @@ -76,13 +63,13 @@ declare module "@databricks/appkit-ui/react" { lane: "obo"; measures: { /** @sqlType DOUBLE */ - "churn_rate": number; + "churn_rate": string | null; }; dimensions: { /** @sqlType STRING */ - "csm_email": string; + "csm_email": string | null; /** @sqlType DATE @timeGrain day|month|quarter|week|year */ - "billing_date": string; + "billing_date": string | null; }; measureKeys: "churn_rate"; dimensionKeys: "csm_email" | "billing_date"; @@ -112,15 +99,15 @@ declare module "@databricks/appkit-ui/react" { lane: "sp"; measures: { /** @sqlType DECIMAL(38,2) */ - "arr": number; + "arr": string | null; /** @sqlType DECIMAL(38,2) */ - "mrr": number; + "mrr": string | null; }; dimensions: { /** @sqlType STRING */ - "region": string; + "region": string | null; /** @sqlType TIMESTAMP @timeGrain day|hour|minute|month|quarter|week|year */ - "created_at": string; + "created_at": string | null; }; measureKeys: "arr" | "mrr"; dimensionKeys: "region" | "created_at"; @@ -151,47 +138,23 @@ declare module "@databricks/appkit-ui/react" { }; } } - -export const metricViewsMetadata = { - "customer_metrics": { - measures: { - "churn_rate": { type: "DOUBLE", display_name: "Churn Rate", format: "0.0%" }, - }, - dimensions: { - "csm_email": { type: "STRING" }, - "billing_date": { type: "DATE" }, - }, - }, - "revenue": { - measures: { - "arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00", description: "Annual recurring revenue" }, - "mrr": { type: "DECIMAL(38,2)", description: "Monthly recurring revenue" }, - }, - dimensions: { - "region": { type: "STRING" }, - "created_at": { type: "TIMESTAMP" }, - }, - }, -} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits an empty MetricRegistry interface when no metrics are registered 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import type {} from "@databricks/appkit-ui/react"; +import "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry {} } - -export const metricViewsMetadata = {} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits permissive types for a degraded entry and accurate empty unions for a confirmed-empty entry 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import type {} from "@databricks/appkit-ui/react"; +import "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { /** Degraded: schema unavailable at type-generation time — permissive types until a successful DESCRIBE refreshes them. */ @@ -216,7 +179,7 @@ declare module "@databricks/appkit-ui/react" { measures: Record; dimensions: { /** @sqlType STRING */ - "region": string; + "region": string | null; }; measureKeys: never; dimensionKeys: "region"; @@ -232,18 +195,5 @@ declare module "@databricks/appkit-ui/react" { }; } } - -export const metricViewsMetadata = { - "cold_metric": { - measures: {}, - dimensions: {}, - }, - "dims_only": { - measures: {}, - dimensions: { - "region": { type: "STRING" }, - }, - }, -} as const; " `; diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 618f42db6..a5d0054b3 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -293,7 +293,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { const metricViewsFolder = path.join(metricsDir, "metric-views"); const outFile = path.join(metricsDir, "generated", "analytics.d.ts"); // Default: the metric .ts is a sibling of `outFile`. - const metricFile = path.join(metricsDir, "generated", "metric-views.ts"); + const metricFile = path.join(metricsDir, "generated", "metric-views.d.ts"); const describeResponse: DatabricksStatementExecutionResponse = { statement_id: "stmt-mock", @@ -326,10 +326,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { }; const writeCommittedMetricTypes = () => { - // Mirrors a real committed metric-views.ts: the augmentation plus the - // runtime const, so a preserved fallback stays a loadable module. + // Mirrors a real committed metric-views.d.ts: augmentation only, since the + // runtime metadata twin ships as the JSON bundle. const committed = - "// committed metric types\nexport const metricViewsMetadata = {};\n"; + '// committed metric types\nimport "@databricks/appkit-ui/react";\n'; fs.mkdirSync(path.dirname(metricFile), { recursive: true }); fs.writeFileSync(metricFile, committed, "utf-8"); return committed; @@ -352,7 +352,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { fs.rmSync(metricsDir, { recursive: true, force: true }); }); - test("writes metric-views.ts when definitions.json exists", async () => { + test("writes metric-views.d.ts when definitions.json exists", async () => { writeMetricConfig(); await expect( @@ -367,20 +367,26 @@ describe("generateFromEntryPoint — metric-view emission", () => { const declarations = fs.readFileSync(metricFile, "utf-8"); expect(declarations).toContain("interface MetricRegistry"); expect(declarations).toContain('"revenue"'); - expect(declarations).toContain('"total_revenue": number'); - expect(declarations).toContain('"region": string'); - // Semantic metadata (SQL type) rides in the type-level `metadata` - // block — the sole carrier now that the JSON bundle is gone. + expect(declarations).toContain('"total_revenue": string | null'); + expect(declarations).toContain('"region": string | null'); + // Semantic metadata (SQL type) rides in the type-level `metadata` block. expect(declarations).toContain('"DECIMAL(38,2)"'); - // The generated file is a real `.ts`, so it also carries the runtime - // `metricViewsMetadata` const (value twin of the type-level metadata). - expect(declarations).toContain("export const metricViewsMetadata"); - expect(declarations).toContain("as const"); - // ...and a type-only import, never a runtime side-effect one. - expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); - expect(declarations).toContain( - 'import type {} from "@databricks/appkit-ui/react"', + // Declaration-only: the value twin lives in the JSON bundle beside + // definitions.json, so nothing here compiles to runtime code. + expect(declarations).not.toContain("export const"); + expect(declarations).toContain('import "@databricks/appkit-ui/react";'); + + const bundle = JSON.parse( + fs.readFileSync( + path.join(metricViewsFolder, "metadata.generated.json"), + "utf-8", + ), + ); + expect(bundle.version).toBe(1); + expect(bundle.metricViews.revenue.measures.total_revenue.type).toBe( + "DECIMAL(38,2)", ); + expect(bundle.metricViews.revenue.dimensions.region.type).toBe("STRING"); }); test("emits no metric artifacts and no errors when definitions.json is absent", async () => { @@ -564,7 +570,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { }), ); const declarations = fs.readFileSync(metricFile, "utf-8"); - expect(declarations).toContain('"total_revenue": number'); + expect(declarations).toContain('"total_revenue": string | null'); // The SQL type rides in the .d.ts type-level `metadata` block. expect(declarations).toContain('"DECIMAL(38,2)"'); }); @@ -590,7 +596,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.waitUntilRunning).not.toHaveBeenCalled(); expect(mocks.executeStatement).toHaveBeenCalledTimes(1); expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); }); @@ -673,7 +679,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as Error).message).toContain("metric-views.ts"); + expect((error as Error).message).toContain("metric-views.d.ts"); expect(fs.existsSync(outFile)).toBe(true); expect(fs.existsSync(metricFile)).toBe(false); }); @@ -783,7 +789,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(order).toEqual([...order].sort((a, b) => a - b)); expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); }); @@ -996,7 +1002,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.waitUntilRunning).not.toHaveBeenCalled(); expect(vi.mocked(createWorkspaceClient)).not.toHaveBeenCalled(); expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); }); @@ -1068,7 +1074,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.getWarehouseState).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); }); @@ -1142,7 +1148,7 @@ describe("generateFromEntryPoint — metric cache section", () => { // derives it from queryFolder when not passed explicitly. const metricViewsFolder = path.join(cacheTestDir, "metric-views"); const outFile = path.join(cacheTestDir, "generated", "analytics.d.ts"); - const metricFile = path.join(cacheTestDir, "generated", "metric-views.ts"); + const metricFile = path.join(cacheTestDir, "generated", "metric-views.d.ts"); const describeResponseFor = ( measure: string, @@ -1289,7 +1295,7 @@ describe("generateFromEntryPoint — metric cache section", () => { expect(savedCache().metrics.revenue.schema.degraded).not.toBe(true); // Artifacts mix the cached real schema with the degraded newcomer. expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); // Pass 2: blocking with the warehouse RUNNING. churn is uncached, so it is @@ -1311,8 +1317,8 @@ describe("generateFromEntryPoint — metric cache section", () => { expect(savedCache().metrics.churn.schema.degraded).not.toBe(true); const refreshed = fs.readFileSync(metricFile, "utf-8"); - expect(refreshed).toContain('"monthly_churn": number'); - expect(refreshed).toContain('"total_revenue": number'); + expect(refreshed).toContain('"monthly_churn": string | null'); + expect(refreshed).toContain('"total_revenue": string | null'); expect(refreshed).not.toContain("measureKeys: string"); }); @@ -1335,7 +1341,7 @@ describe("generateFromEntryPoint — metric cache section", () => { // The .d.ts carries the cached REAL unions — not degraded-open types — // and its type-level `metadata` block still carries the SQL type. const declarations = fs.readFileSync(metricFile, "utf-8"); - expect(declarations).toContain('"total_revenue": number'); + expect(declarations).toContain('"total_revenue": string | null'); expect(declarations).not.toContain("measureKeys: string"); expect(declarations).toContain('"DECIMAL(38,2)"'); // The good entry survived the warehouse-down pass un-overwritten. @@ -1591,7 +1597,7 @@ describe("generateFromEntryPoint — metric cache section", () => { expect(savedCache().metrics.revenue.retry).toBe(false); expect(savedCache().metrics.revenue.schema.degraded).not.toBe(true); expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); }); @@ -1689,7 +1695,7 @@ describe("generateFromEntryPoint — metric cache section", () => { expect(metrics.revenue.retry).toBe(false); expect(metrics.revenue.schema.degraded).toBeUndefined(); expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); }); @@ -1804,7 +1810,9 @@ describe("generateFromEntryPoint — metric cache section", () => { await expect(run()).resolves.toBeUndefined(); expect(mocks.executeStatement).not.toHaveBeenCalled(); expect(mocks.getWarehouseState).not.toHaveBeenCalled(); - expect(fs.readFileSync(metricFile, "utf-8")).toContain('"m": number'); + expect(fs.readFileSync(metricFile, "utf-8")).toContain( + '"m": string | null', + ); }); test.each<[string, Record]>([ @@ -1860,7 +1868,7 @@ describe("generateFromEntryPoint — metric cache section", () => { ); // The artifacts render the fresh schema — never the revived garbage. expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', + '"total_revenue": string | null', ); }, ); @@ -1872,7 +1880,11 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { const queryFolder = path.join(antiClobberDir, "queries"); const metricViewsFolder = path.join(antiClobberDir, "metric-views"); const outFile = path.join(antiClobberDir, "generated", "analytics.d.ts"); - const metricFile = path.join(antiClobberDir, "generated", "metric-views.ts"); + const metricFile = path.join( + antiClobberDir, + "generated", + "metric-views.d.ts", + ); const degradedQuerySchema = (name: string) => ({ name, @@ -1975,7 +1987,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(content).toContain("offline_query"); }); - test("blocking mode + degraded metric (no failures): no write to metric-views.ts", async () => { + test("blocking mode + degraded metric (no failures): no write to metric-views.d.ts", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2035,7 +2047,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(fs.existsSync(outFile)).toBe(false); }); - test("blocking mode + non-degraded metric: writes to metric-views.ts normally", async () => { + test("blocking mode + non-degraded metric: writes to metric-views.d.ts normally", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2080,10 +2092,10 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { const content = fs.readFileSync(metricFile, "utf-8"); expect(content).toContain("interface MetricRegistry"); expect(content).toContain("revenue"); - expect(content).toContain('"total_revenue": number'); + expect(content).toContain('"total_revenue": string | null'); }); - test("non-blocking mode + degraded metric: writes to metric-views.ts anyway", async () => { + test("non-blocking mode + degraded metric: writes to metric-views.d.ts anyway", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2175,7 +2187,11 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { const queryFolder = path.join(warningTestDir, "queries"); const metricViewsFolder = path.join(warningTestDir, "metric-views"); const outFile = path.join(warningTestDir, "generated", "analytics.d.ts"); - const metricFile = path.join(warningTestDir, "generated", "metric-views.ts"); + const metricFile = path.join( + warningTestDir, + "generated", + "metric-views.d.ts", + ); beforeEach(() => { vi.clearAllMocks(); @@ -2395,7 +2411,7 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { } }); - test("metric config + missing metric-views.ts + environmental failure → crash", async () => { + test("metric config + missing metric-views.d.ts + environmental failure → crash", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2426,9 +2442,9 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { expect(error).toBeInstanceOf(TypegenFatalError); const message = stripAnsi((error as Error).message); // Per-surface gate: only the metric surface failed, so the remedy names - // metric-views.ts specifically rather than the whole artifact set. + // metric-views.d.ts specifically rather than the whole artifact set. expect(message).toContain( - "required committed type artifact is missing: metric-views.ts", + "required committed type artifact is missing: metric-views.d.ts", ); expect(message).toContain("commit the generated type files"); expect( @@ -2543,7 +2559,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", beforeEach(() => { vi.clearAllMocks(); mocks.cacheFile.contents = undefined; - // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.ts. + // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.d.ts. fs.rmSync(gateDir, { recursive: true, force: true }); fs.mkdirSync(queryFolder, { recursive: true }); // A degraded query in blocking mode → write suppressed → nothing on disk. @@ -2581,7 +2597,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", }); test("blocking + environmental failure + only serving.d.ts present → still crashes (serving excluded from gate)", async () => { - // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.ts stay absent. + // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.d.ts stay absent. fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync( path.join(path.dirname(outFile), "serving.d.ts"), diff --git a/packages/appkit/src/type-generator/tests/mv-registry.test.ts b/packages/appkit/src/type-generator/tests/mv-registry.test.ts index ff78a15fa..8e58ad97c 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -14,7 +14,10 @@ import { extractMetricColumns, parseDescribeTableExtendedJson, } from "../mv-registry/describe"; -import { generateMetricTypeDeclarations } from "../mv-registry/render-types"; +import { + buildMetricMetadataBundle, + generateMetricTypeDeclarations, +} from "../mv-registry/render-types"; import { syncMetrics } from "../mv-registry/sync"; import type { DatabricksStatementExecutionResponse } from "../types"; @@ -1531,6 +1534,12 @@ describe("generateMetricTypeDeclarations — snapshot", () => { expect(output).toContain('lane: "obo"'); expect(output).toContain('format: "$#,##0.00"'); expect(output).toContain('format: "0.0%"'); + // Metric queries use the JSON_ARRAY wire contract: scalar cells arrive as + // strings and every selected column may be SQL NULL. Generated row values + // must describe that runtime shape rather than claiming JS numbers. + expect(output).toContain('"arr": string | null'); + expect(output).toContain('"churn_rate": string | null'); + expect(output).not.toContain('"arr": number'); }); test("emits an empty MetricRegistry interface when no metrics are registered", () => { @@ -1617,16 +1626,16 @@ describe("generateMetricTypeDeclarations — snapshot", () => { expect(output).toContain( "@timeGrain day|hour|minute|month|quarter|week|year", ); - expect(output).toContain('"created_at": string'); - expect(output).toContain('"region": string'); + expect(output).toContain('"created_at": string | null'); + expect(output).toContain('"region": string | null'); }); }); -// ── The emitted file is a real `.ts` carrying the erasable `declare module` -// augmentation alongside a runtime `metricViewsMetadata` value, so its header -// must stay a type-only import. See `generateMetricTypeDeclarations`. -describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", () => { - test("emits both the declare-module augmentation and the metricViewsMetadata const", async () => { +// ── The declaration file is ambient (`.d.ts`) and carries only the erasable +// `declare module` augmentation; the runtime metadata twin ships as the JSON +// bundle built by `buildMetricMetadataBundle`. +describe("metric metadata bundle", () => { + test("the declaration output carries the augmentation and no runtime value", async () => { const resolution = resolveMetricConfig({ metricViews: { revenue: { source: "appkit_demo.public.revenue_metrics" }, @@ -1648,38 +1657,74 @@ describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", const { schemas } = await syncMetrics(resolution, fetcher); const output = generateMetricTypeDeclarations(schemas); - // Type half: the augmentation is still present, unchanged in shape. expect(output).toContain('declare module "@databricks/appkit-ui/react"'); expect(output).toContain("interface MetricRegistry"); - // Value half: a runtime const conforming to MetricViewsMetadata, `as const`. - expect(output).toContain("export const metricViewsMetadata = {"); - expect(output).toContain("} as const;"); - // The measure/dimension maps carry the same per-column fields as the type - // block (type/display_name/format), keyed by column name. - expect(output).toContain( - '"arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00" }', - ); - expect(output).toContain('"region": { type: "STRING" }'); + // No `export const` at all: an object-literal const in an ambient + // declaration file is a TS1254 error, which is what forced the artifact to + // be a real `.ts` before the bundle took the runtime half. + expect(output).not.toContain("export const"); }); - test("uses a zero-runtime type-only import, never a side-effect import", () => { - const output = generateMetricTypeDeclarations([]); - // A bare `import "..."` in a `.ts` would execute the client entry on the - // Node server. - expect(output).not.toContain('import "@databricks/appkit-ui/react"'); - expect(output).toContain( - 'import type {} from "@databricks/appkit-ui/react"', - ); + test("bundles per-column metadata keyed by metric, measure and dimension", async () => { + const resolution = resolveMetricConfig({ + metricViews: { + revenue: { source: "appkit_demo.public.revenue_metrics" }, + }, + }); + const fetcher = async () => + mockDescribeResponse({ + columns: [ + { + name: "arr", + type: "DECIMAL(38,2)", + is_measure: true, + display_name: "Annual Recurring Revenue", + format: "$#,##0.00", + }, + { name: "region", type: "STRING", is_measure: false }, + ], + }); + const { schemas } = await syncMetrics(resolution, fetcher); + + expect(buildMetricMetadataBundle(schemas)).toEqual({ + version: 1, + metricViews: { + revenue: { + measures: { + arr: { + type: "DECIMAL(38,2)", + display_name: "Annual Recurring Revenue", + format: "$#,##0.00", + }, + }, + dimensions: { region: { type: "STRING" } }, + }, + }, + }); }); - test("emits an empty metricViewsMetadata for no registered metrics", () => { + test("anchors the augmentation with an import of the module it augments", () => { const output = generateMetricTypeDeclarations([]); - expect(output).toContain("export const metricViewsMetadata = {} as const;"); + // Load-bearing: the import marks the file a module, which is what makes + // `declare module` merge into the real one. Drop it and the block becomes an + // ambient declaration that shadows the module, hiding its real exports. + // Matches the sibling analytics.d.ts / serving.d.ts header form. + expect(output).toContain('import "@databricks/appkit-ui/react";'); + expect(output).toContain('declare module "@databricks/appkit-ui/react"'); + }); + + test("bundles no metric views for no registered metrics", () => { + expect(buildMetricMetadataBundle([])).toEqual({ + version: 1, + metricViews: {}, + }); // Empty type augmentation stays too. - expect(output).toContain("interface MetricRegistry {}"); + expect(generateMetricTypeDeclarations([])).toContain( + "interface MetricRegistry {}", + ); }); - test("a degraded schema contributes empty measures/dimensions value maps", async () => { + test("a degraded schema contributes empty measures/dimensions maps", async () => { const resolution = resolveMetricConfig({ metricViews: { cold: { source: "appkit_demo.public.cold" } }, }); @@ -1690,16 +1735,16 @@ describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", status: { state: "PENDING" }, }); const { schemas } = await syncMetrics(resolution, fetcher); - const output = generateMetricTypeDeclarations(schemas); - // Value side of a degraded entry: empty maps, consistent with its - // `Record` metadata type block. - expect(output).toContain(`"cold": { - measures: {}, - dimensions: {}, - }`); + + // Consistent with the degraded entry's `Record` metadata + // type block: the warehouse never described these columns. + expect(buildMetricMetadataBundle(schemas).metricViews.cold).toEqual({ + measures: {}, + dimensions: {}, + }); }); - test("escapes quotes/backticks in display_name and description via JSON.stringify", async () => { + test("carries quotes and backticks verbatim (JSON encoding, not TS literals)", async () => { const resolution = resolveMetricConfig({ metricViews: { revenue: { source: "appkit_demo.public.revenue" } }, }); @@ -1710,20 +1755,22 @@ describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", name: "arr", type: "DECIMAL(38,2)", is_measure: true, - // A double quote AND a backtick — both must survive into a valid - // TS string literal in the runtime const. display_name: 'Net "ARR" `growth`', comment: 'Revenue with a " quote', }, ], }); const { schemas } = await syncMetrics(resolution, fetcher); - const output = generateMetricTypeDeclarations(schemas); + const bundle = buildMetricMetadataBundle(schemas); - // JSON.stringify escapes the embedded double quotes; the backtick rides - // through unescaped inside a double-quoted literal (valid TS). - expect(output).toContain('display_name: "Net \\"ARR\\" `growth`"'); - expect(output).toContain('description: "Revenue with a \\" quote"'); + // The value round-trips through JSON.stringify at the write site, so the + // in-memory bundle holds the raw string rather than an escaped literal. + expect(bundle.metricViews.revenue.measures.arr).toEqual({ + type: "DECIMAL(38,2)", + display_name: 'Net "ARR" `growth`', + description: 'Revenue with a " quote', + }); + expect(JSON.parse(JSON.stringify(bundle))).toEqual(bundle); }); }); diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index 239118c6c..7ade162e9 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -128,7 +128,7 @@ describe("syncMetricViewsTypes", () => { tmpRoot, "shared", "appkit-types", - "metric-views.ts", + "metric-views.d.ts", ); }); @@ -146,7 +146,7 @@ describe("syncMetricViewsTypes", () => { metricFetcher: fetcher, }); - // The generated .ts exists on disk. + // The generated .d.ts exists on disk. expect(fs.existsSync(metricOutFile)).toBe(true); // Result reports both keys, no failures, config present. @@ -158,15 +158,15 @@ describe("syncMetricViewsTypes", () => { ]); expect(result.metricOutFile).toBe(metricOutFile); - // --- metric-views.ts: MetricRegistry augmentation for both metrics --- + // --- metric-views.d.ts: MetricRegistry augmentation for both metrics --- const declarations = fs.readFileSync(metricOutFile, "utf-8"); expect(declarations).toContain("interface MetricRegistry"); expect(declarations).toContain('"revenue"'); expect(declarations).toContain('"churn"'); - // Measure + dimension column types render as TS primitives. - expect(declarations).toContain('"total_revenue": number'); - expect(declarations).toContain('"region": string'); - expect(declarations).toContain('"churn_rate": number'); + // Measure + dimension columns reflect nullable JSON_ARRAY wire values. + expect(declarations).toContain('"total_revenue": string | null'); + expect(declarations).toContain('"region": string | null'); + expect(declarations).toContain('"churn_rate": string | null'); // The OBO metric's lane is captured in its entry. expect(declarations).toContain('lane: "obo"'); expect(declarations).toContain('lane: "sp"'); @@ -175,32 +175,28 @@ describe("syncMetricViewsTypes", () => { // The semantic metadata (format spec, SQL type) rides in the type-level // `metadata` block — the sole carrier now the JSON is gone. expect(declarations).toContain('"$#,##0.00"'); - // The file is a real `.ts`, so it also carries the runtime const and a - // type-only import. - expect(declarations).toContain("export const metricViewsMetadata"); - expect(declarations).toContain("as const"); - expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); - expect(declarations).toContain( - 'import type {} from "@databricks/appkit-ui/react"', - ); + // Declaration-only: the runtime metadata twin lives in the JSON bundle, so + // nothing here compiles to a value. The import anchors the augmentation. + expect(declarations).not.toContain("export const"); + expect(declarations).toContain('import "@databricks/appkit-ui/react";'); }); - test("removes a stale sibling metric-views.d.ts left by a pre-.ts version on upgrade", async () => { + test("removes a stale sibling metric-views.ts left by the interim .ts version on upgrade", async () => { writeMixedConfig(); - // Simulate an app upgraded from a version that emitted an ambient - // `metric-views.d.ts`, which would duplicate the augmentation if left - // beside the new `.ts`. - const staleDts = path.join( + // Simulate an app upgraded from the interim version that emitted a real + // `metric-views.ts` (it carried the runtime metadata const). Left beside the + // new `.d.ts` it would duplicate the augmentation. + const staleTs = path.join( tmpRoot, "shared", "appkit-types", - "metric-views.d.ts", + "metric-views.ts", ); - fs.mkdirSync(path.dirname(staleDts), { recursive: true }); + fs.mkdirSync(path.dirname(staleTs), { recursive: true }); fs.writeFileSync( - staleDts, - '// old\nimport "@databricks/appkit-ui/react";\n', + staleTs, + '// old\nimport type {} from "@databricks/appkit-ui/react";\nexport const metricViewsMetadata = {} as const;\n', ); await syncMetricViewsTypes({ @@ -210,12 +206,12 @@ describe("syncMetricViewsTypes", () => { metricFetcher: fetcher, }); - // The new .ts is written and the stale .d.ts sibling is swept. + // The new .d.ts is written and the stale .ts sibling is swept. expect(fs.existsSync(metricOutFile)).toBe(true); - expect(fs.existsSync(staleDts)).toBe(false); + expect(fs.existsSync(staleTs)).toBe(false); }); - test("preserves a legacy metric-views.d.ts when a degraded blocking pass suppresses the replacement write", async () => { + test("preserves committed metric types when a degraded blocking pass suppresses the replacement write", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -223,15 +219,9 @@ describe("syncMetricViewsTypes", () => { }), ); - const legacyDts = path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.d.ts", - ); - fs.mkdirSync(path.dirname(legacyDts), { recursive: true }); - const committedContent = "// committed legacy metric types\n"; - fs.writeFileSync(legacyDts, committedContent); + fs.mkdirSync(path.dirname(metricOutFile), { recursive: true }); + const committedContent = "// committed metric types\n"; + fs.writeFileSync(metricOutFile, committedContent); await syncMetricViewsTypes({ metricViewsFolder, @@ -245,8 +235,35 @@ describe("syncMetricViewsTypes", () => { }), }); - expect(fs.existsSync(metricOutFile)).toBe(false); - expect(fs.readFileSync(legacyDts, "utf-8")).toBe(committedContent); + expect(fs.readFileSync(metricOutFile, "utf-8")).toBe(committedContent); + // The bundle is written under the same gate, so a suppressed pass leaves it + // absent rather than pairing committed types with emptied-out metadata. + expect( + fs.existsSync(path.join(metricViewsFolder, "metadata.generated.json")), + ).toBe(false); + }); + + test("writes the metadata bundle beside definitions.json", async () => { + writeMixedConfig(); + + await syncMetricViewsTypes({ + metricViewsFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + + const bundlePath = path.join(metricViewsFolder, "metadata.generated.json"); + expect(fs.existsSync(bundlePath)).toBe(true); + + const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf-8")); + expect(bundle.version).toBe(1); + expect(Object.keys(bundle.metricViews).sort()).toEqual([ + "churn", + "revenue", + ]); + // The format spec that used to ride in the runtime const now rides here. + expect(JSON.stringify(bundle)).toContain("$#,##0.00"); }); test("returns noConfig and writes nothing when definitions.json is absent", async () => { @@ -295,8 +312,8 @@ describe("syncMetricViewsTypes", () => { ]); // Cached schemas still render the real (non-degraded) types. const declarations = fs.readFileSync(metricOutFile, "utf-8"); - expect(declarations).toContain('"total_revenue": number'); - expect(declarations).toContain('"churn_rate": number'); + expect(declarations).toContain('"total_revenue": string | null'); + expect(declarations).toContain('"churn_rate": string | null'); }); test("cache: false (--no-cache) re-describes every key even when a warm cache exists", async () => { @@ -359,7 +376,7 @@ describe("syncMetricViewsTypes", () => { expect(fetcher).toHaveBeenCalledTimes(1); expect(second.failures).toEqual([]); const declarations = fs.readFileSync(metricOutFile, "utf-8"); - expect(declarations).toContain('"total_revenue": number'); + expect(declarations).toContain('"total_revenue": string | null'); }); test("a removed metric key is pruned from the cache section", async () => { diff --git a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts index 52c25c9fc..36a89f7b1 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -410,7 +410,10 @@ describe("appKitTypesPlugin — metric option plumbing", () => { ); }); - test("rejects a .d.ts custom mvOutFile up front (it would emit a runtime const into an ambient decl → TS1039)", () => { + // A `.d.ts` mvOutFile was rejected while the metric artifact carried a + // runtime const (illegal in an ambient declaration file). The const now ships + // as a JSON bundle, so a declaration-only path is the normal case. + test("accepts a .d.ts custom mvOutFile", () => { const plugin = appKitTypesPlugin({ mvOutFile: "custom/types/metric-views.d.ts", }); @@ -420,7 +423,7 @@ describe("appKitTypesPlugin — metric option plumbing", () => { ); expect(() => configResolved({ root: path.join(process.cwd(), "client") }), - ).toThrow(/must be a \.ts file, not a \.d\.ts/); + ).not.toThrow(); }); }); diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 3c79fc193..a9a46e83c 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -332,17 +332,6 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // final path is identical (the default outFile above lives in // shared//), and a customized outFile now keeps its metric // sibling next to it instead of pinning it under shared/. - // - // Reject a `.d.ts` metric out-path up front: the metric file is a real - // `.ts` source carrying a runtime `const` (metricViewsMetadata), which is - // illegal inside an ambient declaration file (TS1039). Fail fast with a - // clear message rather than emitting a file that won't compile. - if (options?.mvOutFile?.endsWith(".d.ts")) { - throw new Error( - `appKitTypesPlugin: mvOutFile must be a .ts file, not a .d.ts (got "${options.mvOutFile}"). ` + - "The metric-views file carries a runtime const, which cannot live in an ambient .d.ts.", - ); - } mvOutFile = options?.mvOutFile !== undefined ? path.resolve(projectRoot, options.mvOutFile) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4b7c08ba1..e1dfb7ac6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from "./agent"; export * from "./cache"; export * from "./execute"; export * from "./genie"; +export * from "./metric-filter"; export * from "./metric-metadata"; export * from "./plugin"; export * from "./sql"; diff --git a/packages/shared/src/metric-filter.ts b/packages/shared/src/metric-filter.ts new file mode 100644 index 000000000..66425a966 --- /dev/null +++ b/packages/shared/src/metric-filter.ts @@ -0,0 +1,47 @@ +export type MetricFilterOperatorName = + | "equals" + | "notEquals" + | "gt" + | "gte" + | "lt" + | "lte" + | "contains" + | "notContains" + | "in" + | "notIn" + | "set" + | "notSet"; + +export interface MetricPredicate { + member: string; + operator: MetricFilterOperatorName; + values?: ReadonlyArray; +} + +export type MetricOrderDirection = "ASC" | "DESC"; + +/** + * `field` must be one of the request's own `measures` or `dimensions` — a + * measure is ordered by its SELECT **alias**, because `ORDER BY MEASURE(...)` is + * rejected by Spark (`METRIC_VIEW_INVALID_MEASURE_FUNCTION_INPUT`). `direction` + * is a closed vocabulary so nothing free-form reaches the SQL string; omitting + * it means `ASC` (the SQL default). + * + * The default `MetricOrderBy` (equivalent to `MetricOrderBy`) is the + * broad wire/server form. When extracting a reusable `orderBy` array for + * `useMetricView`, parameterize it with the selected field literals so the hook + * can verify that every ordering field was selected. + */ +export interface MetricOrderBy { + field: Field; + direction?: MetricOrderDirection; +} + +/** + * Recursive filter expression for the metric-view request body: a leaf + * {@link MetricPredicate} or an `{ and: [...] }` / `{ or: [...] }` group. + */ +export type MetricFilter = + | MetricPredicate + | { and: ReadonlyArray } + | { or: ReadonlyArray }; diff --git a/packages/shared/src/schemas/metric-metadata-bundle.ts b/packages/shared/src/schemas/metric-metadata-bundle.ts new file mode 100644 index 000000000..f6a4f1ad9 --- /dev/null +++ b/packages/shared/src/schemas/metric-metadata-bundle.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { metricKeySchema } from "./metric-source"; + +// Bundle format version. +export const METRIC_METADATA_BUNDLE_VERSION = 1; +export const METRIC_METADATA_FILE = "metadata.generated.json"; + +const columnDisplaySchema = z + .object({ + type: z + .string() + .describe("SQL type of the column as reported by DESCRIBE."), + display_name: z + .string() + .optional() + .describe("Human label from the metric view's YAML `display_name`."), + format: z + .string() + .optional() + .describe( + 'Spark number-format spec from the metric view\'s YAML `format`, e.g. "$#,##0.00".', + ), + description: z + .string() + .optional() + .describe("Column description from the metric view's YAML."), + }) + // Non-strict: a newer generator may add per-column fields, and an older + // runtime should keep serving the fields it does understand rather than + // rejecting the whole bundle over one unknown key. + .describe("Display metadata for a single metric-view column."); + +const metricEntryMetadataSchema = z + .object({ + measures: z.record(z.string(), columnDisplaySchema), + dimensions: z.record(z.string(), columnDisplaySchema), + }) + .describe("Per-column display metadata for one metric view."); + +export const metricMetadataBundleSchema = z + .object({ + version: z + .number() + .int() + .describe( + "Bundle format version. Compared against METRIC_METADATA_BUNDLE_VERSION.", + ), + metricViews: z + .record(metricKeySchema, metricEntryMetadataSchema) + .describe("Per-column display metadata, keyed by metric key."), + }) + .describe( + "Schema for AppKit config/metric-views/metadata.generated.json — build-generated per-column display metadata for the analytics plugin's metric-view path. Generated; do not hand-edit.", + ); + +export type MetricMetadataBundle = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e45b03b5..660c3be49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: '@opentelemetry/core@<2.8.0': 2.8.0 protobufjs@<7.6.2: 7.6.2 qs@<6.15.2: 6.15.2 + size-sensor: 1.0.3 importers: @@ -10587,8 +10588,8 @@ packages: engines: {node: '>=12.0.0', npm: '>=5.6.0'} hasBin: true - size-sensor@1.0.2: - resolution: {integrity: sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==} + size-sensor@1.0.3: + resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} @@ -19263,7 +19264,7 @@ snapshots: echarts: 6.0.0 fast-deep-equal: 3.1.3 react: 19.2.0 - size-sensor: 1.0.2 + size-sensor: 1.0.3 echarts@6.0.0: dependencies: @@ -23874,7 +23875,7 @@ snapshots: arg: 5.0.2 sax: 1.4.3 - size-sensor@1.0.2: {} + size-sensor@1.0.3: {} skin-tone@2.0.0: dependencies: