From 62aae9aed0185e34d0527f4f89c02a9a2aab9434 Mon Sep 17 00:00:00 2001 From: Razvan Placintar Date: Wed, 27 May 2026 09:11:53 +0300 Subject: [PATCH] feat(apollo-vertex): charts insights adapter --- .../app/api/datafabric/[...path]/route.ts | 66 +------ .../app/api/insights/[...path]/route.ts | 24 +++ .../app/components/bar-chart/page.mdx | 92 ++++++++++ .../components/distribution-chart/page.mdx | 96 +++++++++- .../app/components/kpi-chart/page.mdx | 81 ++++++++- .../app/components/line-chart/page.mdx | 94 ++++++++++ .../app/components/multi-line-chart/page.mdx | 132 +++++++++++++- .../app/components/table-chart/page.mdx | 126 ++++++++++++- apps/apollo-vertex/lib/api-proxy.ts | 57 ++++++ apps/apollo-vertex/registry.json | 155 ++++++++++++++-- .../data-fabric-table/table-data-model.ts | 15 +- .../tools/data-fabric/util/chart-helpers.ts | 124 +++++++------ .../bar-chart/bar-chart-with-adapter.tsx | 2 +- .../chart-data-mapper.ts | 39 ++-- .../registry/charts-core/chart-models.ts | 31 ++-- .../registry/charts-core/charts-core.ts | 19 +- .../data-query-response-schema.ts | 0 .../charts-core/models/aggregation.ts | 16 -- .../base-chart-configuration.ts | 1 + .../registry/charts-core/models/expression.ts | 24 +++ .../registry/charts-core/models/field.ts | 52 ++++++ .../registry/charts-core/table-data-model.ts | 10 +- .../registry/charts-core/util/build-field.ts | 12 ++ .../charts-core/util/data-type-alignment.ts | 7 +- .../util/format/format-metric-value.ts | 33 +++- .../charts-core/util/format/format.ts | 4 +- .../charts-core/util/get-metric-field-type.ts | 22 +++ .../registry/data-fabric-adapter/adapter.ts | 7 +- .../data-fabric-adapter/chart-adapters/bar.ts | 2 +- .../chart-adapters/table.ts | 8 +- .../schemas/query-schema.ts | 8 +- .../utils/fetch-date-binned-data.ts | 4 +- .../utils/metric-aggregate.ts | 43 +++-- .../utils/response-data-mapper.ts | 10 +- .../distribution-chart-with-adapter.tsx | 2 +- .../registry/insights-adapter/adapter.ts | 68 +++++++ .../insights-adapter/chart-adapters/bar.ts | 56 ++++++ .../chart-adapters/distribution.ts | 168 ++++++++++++++++++ .../insights-adapter/chart-adapters/kpi.ts | 60 +++++++ .../insights-adapter/chart-adapters/line.ts | 138 ++++++++++++++ .../chart-adapters/multi-line.ts | 150 ++++++++++++++++ .../insights-adapter/chart-adapters/table.ts | 44 +++++ .../registry/insights-adapter/contract.ts | 28 +++ .../insights-adapter/insights-adapter.ts | 2 + .../schemas/aggregate-fragment-schema.ts | 167 +++++++++++++++++ .../schemas/filter-fragment-schema.ts | 67 +++++++ .../schemas/filter-request-schema.ts | 22 +++ .../insights-adapter/schemas/query-schema.ts | 33 ++++ .../utils/assert-configuration-supported.ts | 20 +++ .../insights-adapter/utils/binning.ts | 97 ++++++++++ .../insights-adapter/utils/filter-request.ts | 90 ++++++++++ .../utils/metric-aggregate.ts | 21 +++ .../insights-adapter/utils/query-options.ts | 36 ++++ .../registry/insights-adapter/utils/query.ts | 35 ++++ .../insights-adapter/utils/throw-error.ts | 6 + .../kpi-chart/kpi-chart-with-adapter.tsx | 2 +- .../line-chart/line-chart-with-adapter.tsx | 2 +- .../multi-line-chart-with-adapter.tsx | 4 +- apps/apollo-vertex/tsconfig.json | 3 + 59 files changed, 2486 insertions(+), 251 deletions(-) create mode 100644 apps/apollo-vertex/app/api/insights/[...path]/route.ts create mode 100644 apps/apollo-vertex/lib/api-proxy.ts rename apps/apollo-vertex/registry/{data-fabric-adapter/utils => charts-core}/chart-data-mapper.ts (57%) rename apps/apollo-vertex/registry/{data-fabric-adapter/schemas => charts-core}/data-query-response-schema.ts (100%) delete mode 100644 apps/apollo-vertex/registry/charts-core/models/aggregation.ts create mode 100644 apps/apollo-vertex/registry/charts-core/models/expression.ts create mode 100644 apps/apollo-vertex/registry/charts-core/models/field.ts create mode 100644 apps/apollo-vertex/registry/charts-core/util/build-field.ts create mode 100644 apps/apollo-vertex/registry/charts-core/util/get-metric-field-type.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/adapter.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/chart-adapters/bar.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/chart-adapters/distribution.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/chart-adapters/kpi.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/chart-adapters/line.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/chart-adapters/multi-line.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/chart-adapters/table.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/contract.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/insights-adapter.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/schemas/aggregate-fragment-schema.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/schemas/filter-fragment-schema.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/schemas/filter-request-schema.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/schemas/query-schema.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/utils/assert-configuration-supported.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/utils/binning.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/utils/filter-request.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/utils/metric-aggregate.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/utils/query-options.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/utils/query.ts create mode 100644 apps/apollo-vertex/registry/insights-adapter/utils/throw-error.ts diff --git a/apps/apollo-vertex/app/api/datafabric/[...path]/route.ts b/apps/apollo-vertex/app/api/datafabric/[...path]/route.ts index a15d14e13..787b1b0c4 100644 --- a/apps/apollo-vertex/app/api/datafabric/[...path]/route.ts +++ b/apps/apollo-vertex/app/api/datafabric/[...path]/route.ts @@ -1,44 +1,15 @@ -import { type NextRequest, NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { proxyToUiPath } from "@/lib/api-proxy"; -function getAuthHeader(request: NextRequest) { - const authHeader = request.headers.get("authorization"); - if (!authHeader) { - return null; - } - return authHeader; -} - -function buildTargetUrl(path: string[], search: string) { - return `https://alpha.uipath.com/${path.join("/")}${search}`; -} +const DATAFABRIC_SEGMENT = "datafabric_"; export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> }, ) { - const authHeader = getAuthHeader(request); - if (!authHeader) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const { path } = await params; - const targetUrl = buildTargetUrl(path, request.nextUrl.search); - - const response = await fetch(targetUrl, { - method: "GET", - headers: { - Authorization: authHeader, - }, - signal: request.signal, - }); - - const text = await response.text(); - return new NextResponse(text, { - status: response.status, - headers: { - "Content-Type": - response.headers.get("content-type") ?? "application/json", - }, + return proxyToUiPath(request, path, { + requiredServiceSegment: DATAFABRIC_SEGMENT, }); } @@ -46,31 +17,8 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string[] }> }, ) { - const authHeader = getAuthHeader(request); - if (!authHeader) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const { path } = await params; - const targetUrl = buildTargetUrl(path, request.nextUrl.search); - const body = await request.text(); - - const response = await fetch(targetUrl, { - method: "POST", - headers: { - "Content-Type": request.headers.get("content-type") ?? "application/json", - Authorization: authHeader, - }, - body, - signal: request.signal, - }); - - const text = await response.text(); - return new NextResponse(text, { - status: response.status, - headers: { - "Content-Type": - response.headers.get("content-type") ?? "application/json", - }, + return proxyToUiPath(request, path, { + requiredServiceSegment: DATAFABRIC_SEGMENT, }); } diff --git a/apps/apollo-vertex/app/api/insights/[...path]/route.ts b/apps/apollo-vertex/app/api/insights/[...path]/route.ts new file mode 100644 index 000000000..76f01a51b --- /dev/null +++ b/apps/apollo-vertex/app/api/insights/[...path]/route.ts @@ -0,0 +1,24 @@ +import type { NextRequest } from "next/server"; +import { proxyToUiPath } from "@/lib/api-proxy"; + +const INSIGHTS_SEGMENT = "insightsrtm_"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + return proxyToUiPath(request, path, { + requiredServiceSegment: INSIGHTS_SEGMENT, + }); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + return proxyToUiPath(request, path, { + requiredServiceSegment: INSIGHTS_SEGMENT, + }); +} diff --git a/apps/apollo-vertex/app/components/bar-chart/page.mdx b/apps/apollo-vertex/app/components/bar-chart/page.mdx index 5c4325117..bf1cec0f7 100644 --- a/apps/apollo-vertex/app/components/bar-chart/page.mdx +++ b/apps/apollo-vertex/app/components/bar-chart/page.mdx @@ -13,3 +13,95 @@ A metric broken down by a categorical dimension. ```bash npx shadcn@latest add @uipath/bar-chart ``` + +Installs both the prop-driven `BarChart` view and the `BarChartWithAdapter` variant. + +## View + +`BarChart` renders pre-shaped rows. Use it when the data is already in memory. + +```tsx +import { BarChart } from "@/components/ui/bar-chart"; + +const rows = [ + { + id: "engineering", + dimensions: [{ key: "department", label: "Engineering" }], + values: { spend: 28000 }, + formattedValues: { spend: "$28,000" }, + formattedPercents: { spend: "32.2%" }, + }, + // ... +]; + + +``` + +### Props + +- `rows: BarChartRow[]` — one row per category. Each row carries its dimensions, raw `values`, and pre-formatted `formattedValues`/`formattedPercents` for the tooltip and labels. +- `series: BarChartSeries[]` — one entry per metric. With multiple series, bars are grouped per category. Set `color` to override the palette. + +## With adapter + +`BarChartWithAdapter` wires the chart to a `DataAdapter`. It manages its own data fetching, Suspense, and error boundary — drop it anywhere a `QueryClientProvider` is in scope. + +The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself): + +```bash +npx shadcn@latest add @uipath/data-fabric-adapter +# or +npx shadcn@latest add @uipath/insights-adapter +``` + +```tsx +import { BarChartWithAdapter } from "@/components/ui/bar-chart"; +import { dataFabricAdapter } from "@/lib/data-fabric-adapter"; + +const adapter = dataFabricAdapter({ + baseUrl: "/api/datafabric/.../api", + accessToken, + entityName: "Orders", +}); + +const dataModel = { + id: "orders-by-status", + dimensions: [ + { id: "Status", display: "Status", type: "string" as const }, + ], + metrics: [ + { + id: "total", + display: "Total orders", + expression: { + type: "aggregate" as const, + aggregation: "COUNT" as const, + argument: { id: "Id", display: "Id", type: "numeric" as const }, + }, + }, + ], +}; + +const configuration = { + id: "orders-bar", + name: "Orders by status", + type: "bar" as const, + dimensions: ["Status"], + metrics: ["total"], +}; + + +``` + +### Props + +- `configuration: BarChartConfiguration` — `dimensions`/`metrics` reference IDs that must exist in `dataModel`. Optional `filters`, `joins`, `from`, `filterTableId`. +- `dataModel: ChartDataModel` — defines available dimensions (`StringModelField` only — bar charts need a categorical X axis) and metrics with `expression` shape. +- `dataAdapter: DataAdapter` — implementation that runs the query. Use `dataFabricAdapter` or `insightsAdapter` from `@uipath/data-fabric-adapter` / `@uipath/insights-adapter`, or implement the interface yourself. diff --git a/apps/apollo-vertex/app/components/distribution-chart/page.mdx b/apps/apollo-vertex/app/components/distribution-chart/page.mdx index b7685166a..0dacd0c31 100644 --- a/apps/apollo-vertex/app/components/distribution-chart/page.mdx +++ b/apps/apollo-vertex/app/components/distribution-chart/page.mdx @@ -2,7 +2,7 @@ import { DistributionChartTemplate } from '@/templates/charts/DistributionChartT # Distribution Chart -A histogram over a numeric or datetime dimension. +A histogram over a numeric or datetime dimension. Bins are computed by the adapter and rendered as bars.
@@ -13,3 +13,97 @@ A histogram over a numeric or datetime dimension. ```bash npx shadcn@latest add @uipath/distribution-chart ``` + +Installs both the prop-driven `DistributionChart` view and the `DistributionChartWithAdapter` variant. + +## View + +`DistributionChart` plots an array of `{ x, y }` points where `x` is the bin label. Same shape as `LineChart` but rendered as bars (no curve). + +```tsx +import { DistributionChart } from "@/components/ui/distribution-chart"; + +const data = [ + { x: "0–100", y: 12 }, + { x: "100–200", y: 28 }, + { x: "200–300", y: 19 }, + // ... +]; + +const formatValue = (value: number) => + new Intl.NumberFormat("en-US").format(value); + + +``` + +### Props + +- `data: Array<{ x: string; y: number }>` — bin label + count. +- `seriesLabel: string` — tooltip label. +- `formatValue: (value: number) => string` — formats Y values. +- `color?: string` — optional override; defaults to `var(--color-primary)`. + +## With adapter + +`DistributionChartWithAdapter` runs a min/max query, computes bin cutoff points, then queries the binned data — all behind Suspense + error boundary. + +The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself): + +```bash +npx shadcn@latest add @uipath/data-fabric-adapter +# or +npx shadcn@latest add @uipath/insights-adapter +``` + +```tsx +import { DistributionChartWithAdapter } from "@/components/ui/distribution-chart"; +import { dataFabricAdapter } from "@/lib/data-fabric-adapter"; + +const adapter = dataFabricAdapter({ + baseUrl: "/api/datafabric/.../api", + accessToken, + entityName: "Orders", +}); + +const dataModel = { + id: "order-amount-distribution", + dimensions: [ + { id: "Amount", display: "Amount", type: "numeric" as const }, + ], + metrics: [ + { + id: "count", + display: "Orders", + expression: { + type: "aggregate" as const, + aggregation: "COUNT" as const, + argument: { id: "Id", display: "Id", type: "numeric" as const }, + }, + }, + ], +}; + +const configuration = { + id: "amount-distribution", + name: "Order amount distribution", + type: "distribution" as const, + dimensions: ["Amount"], + metrics: ["count"], +}; + + +``` + +### Props + +- `configuration: DistributionChartConfiguration` — single dimension + single metric. +- `dataModel: ChartDataModel` — dimension must be `numeric` or `datetime`. +- `dataAdapter: DataAdapter` — implementation that runs the queries. diff --git a/apps/apollo-vertex/app/components/kpi-chart/page.mdx b/apps/apollo-vertex/app/components/kpi-chart/page.mdx index 8070fd158..f468da32d 100644 --- a/apps/apollo-vertex/app/components/kpi-chart/page.mdx +++ b/apps/apollo-vertex/app/components/kpi-chart/page.mdx @@ -2,7 +2,7 @@ import { KpiChartTemplate } from '@/templates/charts/KpiChartTemplate'; # KPI Chart -A single scalar metric (count, sum, average, min, max). +A single scalar metric. No dimension, no breakdown — just a label and a formatted value.
@@ -13,3 +13,82 @@ A single scalar metric (count, sum, average, min, max). ```bash npx shadcn@latest add @uipath/kpi-chart ``` + +Installs both the prop-driven `KpiChart` view and the `KpiChartWithAdapter` variant. + +## View + +`KpiChart` renders a static label/value pair. Format the value yourself (this component just prints it). + +```tsx +import { KpiChart } from "@/components/ui/kpi-chart"; + + +``` + +### Props + +- `label: string` — small text shown above the value. +- `value: string` — formatted display value. + +## With adapter + +`KpiChartWithAdapter` runs a single aggregate query and renders the result with `formatMetricValue` from charts-core (which respects the metric's expression and field type). + +The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself): + +```bash +npx shadcn@latest add @uipath/data-fabric-adapter +# or +npx shadcn@latest add @uipath/insights-adapter +``` + +```tsx +import { KpiChartWithAdapter } from "@/components/ui/kpi-chart"; +import { dataFabricAdapter } from "@/lib/data-fabric-adapter"; + +const adapter = dataFabricAdapter({ + baseUrl: "/api/datafabric/.../api", + accessToken, + entityName: "Orders", +}); + +const dataModel = { + id: "total-revenue", + metrics: [ + { + id: "revenue", + display: "Total revenue", + expression: { + type: "aggregate" as const, + aggregation: "SUM" as const, + argument: { + id: "Amount", + display: "Amount", + type: "currency" as const, + format: { currency: "USD" }, + }, + }, + }, + ], +}; + +const configuration = { + id: "revenue-kpi", + name: "Total revenue", + type: "kpi" as const, + metrics: ["revenue"], +}; + + +``` + +### Props + +- `configuration: KpiChartConfiguration` — one metric, no dimension. +- `dataModel: KpiDataModel` — metrics only (no `dimensions` field). +- `dataAdapter: DataAdapter` — implementation that runs the query. diff --git a/apps/apollo-vertex/app/components/line-chart/page.mdx b/apps/apollo-vertex/app/components/line-chart/page.mdx index 55cd229ce..d236650c8 100644 --- a/apps/apollo-vertex/app/components/line-chart/page.mdx +++ b/apps/apollo-vertex/app/components/line-chart/page.mdx @@ -13,3 +13,97 @@ A time-series line chart over a datetime dimension. ```bash npx shadcn@latest add @uipath/line-chart ``` + +Installs both the prop-driven `LineChart` view and the `LineChartWithAdapter` variant. + +## View + +`LineChart` plots an array of `{ x, y }` points. Use it when the series is already in memory. + +```tsx +import { LineChart } from "@/components/ui/line-chart"; + +const data = [ + { x: "Jan", y: 120 }, + { x: "Feb", y: 132 }, + { x: "Mar", y: 101 }, + // ... +]; + +const formatValue = (value: number) => + new Intl.NumberFormat("en-US", { notation: "compact" }).format(value); + + +``` + +### Props + +- `data: Array<{ x: string; y: number }>` — points in order. `x` is rendered on the axis as-is. +- `seriesLabel: string` — tooltip/legend label. +- `formatValue: (value: number) => string` — formats Y values in the tooltip and axis. +- `color?: string` — optional override; defaults to `var(--color-primary)`. + +## With adapter + +`LineChartWithAdapter` queries the configured `DataAdapter`, bins the response on the dimension's time axis, and renders the line. Wrapped in Suspense + error boundary. + +The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself): + +```bash +npx shadcn@latest add @uipath/data-fabric-adapter +# or +npx shadcn@latest add @uipath/insights-adapter +``` + +```tsx +import { LineChartWithAdapter } from "@/components/ui/line-chart"; +import { dataFabricAdapter } from "@/lib/data-fabric-adapter"; + +const adapter = dataFabricAdapter({ + baseUrl: "/api/datafabric/.../api", + accessToken, + entityName: "Orders", +}); + +const dataModel = { + id: "orders-over-time", + dimensions: [ + { id: "OrderDate", display: "Order date", type: "datetime" as const }, + ], + metrics: [ + { + id: "total", + display: "Total orders", + expression: { + type: "aggregate" as const, + aggregation: "COUNT" as const, + argument: { id: "Id", display: "Id", type: "numeric" as const }, + }, + }, + ], +}; + +const configuration = { + id: "orders-line", + name: "Orders over time", + type: "line" as const, + dimensions: ["OrderDate"], + metrics: ["total"], +}; + + +``` + +### Props + +- `configuration: LineChartConfiguration` — single dimension + single metric (line charts always render one series). +- `dataModel: ChartDataModel` — dimension must be `datetime`. +- `dataAdapter: DataAdapter` — implementation that runs the query. diff --git a/apps/apollo-vertex/app/components/multi-line-chart/page.mdx b/apps/apollo-vertex/app/components/multi-line-chart/page.mdx index 43b1df722..84dbd841f 100644 --- a/apps/apollo-vertex/app/components/multi-line-chart/page.mdx +++ b/apps/apollo-vertex/app/components/multi-line-chart/page.mdx @@ -2,7 +2,7 @@ import { MultiLineChartTemplate } from '@/templates/charts/MultiLineChartTemplat # Multi-Line Chart -Two metrics on a shared datetime axis with independent Y axes. +Two metrics on a shared datetime axis with independent left and right Y axes.
@@ -13,3 +13,133 @@ Two metrics on a shared datetime axis with independent Y axes. ```bash npx shadcn@latest add @uipath/multi-line-chart ``` + +Installs both the prop-driven `MultiLineChart` view and the `MultiLineChartWithAdapter` variant. + +## View + +`MultiLineChart` plots up to two series sharing an X axis. Pass series with `axis: "left" | "right"` to drive independent Y axes (useful when units differ). + +```tsx +import { MultiLineChart } from "@/components/ui/multi-line-chart"; + +const data = [ + { month: "Jan", orders: 120, revenue: 12000 }, + { month: "Feb", orders: 132, revenue: 14500 }, + // ... +]; + +const numberFormat = new Intl.NumberFormat("en-US"); +const currencyFormat = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); + + numberFormat.format(v), + totalText: numberFormat.format(252), + totalLabel: "Total orders", + }, + { + id: "revenue", + label: "Revenue", + color: "var(--chart-2)", + axis: "right", + formatValue: (v) => currencyFormat.format(v), + totalText: currencyFormat.format(26500), + totalLabel: "Total revenue", + }, + ]} +/> +``` + +### Props + +- `data: Array>` — rows keyed by series id and `xKey`. +- `xKey: string` — which property names the X axis (e.g., `"month"`). +- `series: MultiLineChartSeries[]` — exactly two entries: one `axis: "left"`, one `axis: "right"`. Each carries label, color, formatter, and pre-computed `totalText`/`totalLabel` shown above the chart. + +## With adapter + +`MultiLineChartWithAdapter` issues one query per metric (parallel) against the shared dimension, bins datetime, and renders the dual-axis chart. + +The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself): + +```bash +npx shadcn@latest add @uipath/data-fabric-adapter +# or +npx shadcn@latest add @uipath/insights-adapter +``` + +```tsx +import { MultiLineChartWithAdapter } from "@/components/ui/multi-line-chart"; +import { dataFabricAdapter } from "@/lib/data-fabric-adapter"; + +const adapter = dataFabricAdapter({ + baseUrl: "/api/datafabric/.../api", + accessToken, + entityName: "Orders", +}); + +const idField = { id: "Id", display: "Id", type: "numeric" as const }; +const amountField = { + id: "Amount", + display: "Amount", + type: "numeric" as const, +}; + +const dataModel = { + id: "orders-trends", + dimensions: [ + { id: "OrderDate", display: "Order date", type: "datetime" as const }, + ], + metrics: [ + { + id: "order_count", + display: "Orders", + expression: { + type: "aggregate" as const, + aggregation: "COUNT" as const, + argument: idField, + }, + }, + { + id: "revenue", + display: "Revenue", + expression: { + type: "aggregate" as const, + aggregation: "SUM" as const, + argument: amountField, + }, + }, + ], +}; + +const configuration = { + id: "orders-multi-line", + name: "Orders + revenue over time", + type: "multi_line" as const, + dimensions: ["OrderDate"], + metrics: ["order_count", "revenue"], +}; + + +``` + +### Props + +- `configuration: MultiLineChartConfiguration` — one datetime dimension, exactly two metrics. +- `dataModel: ChartDataModel` — dimension must be `datetime`. +- `dataAdapter: DataAdapter` — implementation that runs the queries. diff --git a/apps/apollo-vertex/app/components/table-chart/page.mdx b/apps/apollo-vertex/app/components/table-chart/page.mdx index d33f0dddb..caa184244 100644 --- a/apps/apollo-vertex/app/components/table-chart/page.mdx +++ b/apps/apollo-vertex/app/components/table-chart/page.mdx @@ -2,7 +2,7 @@ import { TableChartTemplate } from '@/templates/charts/TableChartTemplate'; # Table Chart -A sortable table of entity rows. +A sortable table of entity rows. Use for record-level views — no aggregation, just listing.
@@ -13,3 +13,127 @@ A sortable table of entity rows. ```bash npx shadcn@latest add @uipath/table-chart ``` + +Installs both the prop-driven `TableChart` view and the `TableChartWithAdapter` variant. + +## View + +`TableChart` renders rows with column-level formatting and optional client-controlled sorting. + +```tsx +import { TableChart } from "@/components/ui/table-chart"; + +const rows = [ + { department: "Marketing", headcount: 12, avgSalary: 58000, active: true }, + { department: "Engineering", headcount: 28, avgSalary: 66000, active: false }, + // ... +]; + +const numberFormat = new Intl.NumberFormat("en-US"); + + numberFormat.format(Number(v)), + }, + { + id: "avgSalary", + label: "Avg salary", + align: "right", + format: (v) => `$${numberFormat.format(Number(v))}`, + }, + { + id: "active", + label: "Active", + align: "left", + format: (v) => (v ? "Yes" : "No"), + }, + ]} + rows={rows} +/> +``` + +### Props + +- `columns: TableChartColumn[]` — id, label, alignment, and a per-column `format(value): string` function. +- `rows: Array>` — each row keyed by column id. +- `sort?: TableChartSort | null` — controlled sort state. Omit for uncontrolled sorting. +- `onSortChange?: (sort: TableChartSort | null) => void` — fires when the user clicks a header. + +## With adapter + +`TableChartWithAdapter` fetches rows via the adapter, derives column formatters from the data model's field types (currency, percentage, boolean format, etc.), and threads sort state into the query. + +The chart package does not bundle an adapter. Install one of the ready-made adapters (or implement the `DataAdapter` interface yourself): + +```bash +npx shadcn@latest add @uipath/data-fabric-adapter +# or +npx shadcn@latest add @uipath/insights-adapter +``` + +```tsx +import { useState } from "react"; +import { TableChartWithAdapter } from "@/components/ui/table-chart"; +import type { TableChartState } from "@uipath/charts-core"; +import { dataFabricAdapter } from "@/lib/data-fabric-adapter"; + +const adapter = dataFabricAdapter({ + baseUrl: "/api/datafabric/.../api", + accessToken, + entityName: "Orders", +}); + +const dataModel = { + id: "orders-table", + fields: [ + { id: "CustomerName", display: "Customer", type: "string" as const }, + { + id: "Amount", + display: "Amount", + type: "currency" as const, + format: { currency: "USD" }, + }, + { id: "Status", display: "Status", type: "string" as const }, + { id: "OrderDate", display: "Order date", type: "datetime" as const }, + ], +}; + +const configuration = { + id: "orders-table", + name: "Orders", + type: "table" as const, + dimensions: ["CustomerName", "Amount", "Status", "OrderDate"], +}; + +function OrdersTable() { + const [state, setState] = useState({ sortBy: null }); + + return ( + + ); +} +``` + +### Props + +- `configuration: TableChartConfiguration` — `dimensions` IDs to include as columns. +- `dataModel: TableDataModel` — flat list of `DataModelField`s. Field `type` drives formatting (currency uses `format.currency`, boolean uses `format.trueDisplay`/`falseDisplay`, etc.). +- `dataAdapter: DataAdapter` — implementation that runs the query. +- `state?: TableChartState` — controlled `{ sortBy }`. Sort changes refetch. +- `onStateChange?: (next: TableChartState) => void` — fires when the user changes sort. diff --git a/apps/apollo-vertex/lib/api-proxy.ts b/apps/apollo-vertex/lib/api-proxy.ts new file mode 100644 index 000000000..4d7dc8a2f --- /dev/null +++ b/apps/apollo-vertex/lib/api-proxy.ts @@ -0,0 +1,57 @@ +import { type NextRequest, NextResponse } from "next/server"; + +const UPSTREAM_ORIGIN = "https://alpha.uipath.com"; + +interface ProxyToUiPathOptions { + requiredServiceSegment: string; +} + +function buildTargetUrl(path: string[], search: string) { + return `${UPSTREAM_ORIGIN}/${path.join("/")}${search}`; +} + +export async function proxyToUiPath( + request: NextRequest, + path: string[], + { requiredServiceSegment }: ProxyToUiPathOptions, +): Promise { + const authHeader = request.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (path[2] !== requiredServiceSegment) { + return NextResponse.json( + { + error: `Proxy only forwards to "${requiredServiceSegment}" — refused path "/${path.join("/")}".`, + }, + { status: 403 }, + ); + } + + const targetUrl = buildTargetUrl(path, request.nextUrl.search); + const method = request.method; + const hasBody = method !== "GET" && method !== "HEAD"; + + const headers: HeadersInit = { Authorization: authHeader }; + if (hasBody) { + headers["Content-Type"] = + request.headers.get("content-type") ?? "application/json"; + } + + const response = await fetch(targetUrl, { + method, + headers, + body: hasBody ? await request.text() : null, + signal: request.signal, + }); + + const text = await response.text(); + return new NextResponse(text, { + status: response.status, + headers: { + "Content-Type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 7ef07edad..96f696874 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -347,6 +347,11 @@ "path": "types/luxon.d.ts", "type": "registry:lib" }, + { + "path": "registry/charts-core/chart-data-mapper.ts", + "type": "registry:lib", + "target": "lib/charts-core/chart-data-mapper.ts" + }, { "path": "registry/charts-core/chart-loading-boundary.tsx", "type": "registry:lib", @@ -367,6 +372,11 @@ "type": "registry:lib", "target": "lib/charts-core/data-adapter.ts" }, + { + "path": "registry/charts-core/data-query-response-schema.ts", + "type": "registry:lib", + "target": "lib/charts-core/data-query-response-schema.ts" + }, { "path": "registry/charts-core/error-boundary.tsx", "type": "registry:lib", @@ -428,9 +438,14 @@ "target": "lib/charts-core/models/configurations/table-chart-configuration.ts" }, { - "path": "registry/charts-core/models/aggregation.ts", + "path": "registry/charts-core/models/expression.ts", "type": "registry:lib", - "target": "lib/charts-core/models/aggregation.ts" + "target": "lib/charts-core/models/expression.ts" + }, + { + "path": "registry/charts-core/models/field.ts", + "type": "registry:lib", + "target": "lib/charts-core/models/field.ts" }, { "path": "registry/charts-core/models/filter-values.ts", @@ -581,6 +596,16 @@ "path": "registry/charts-core/util/format/percentage-format-options.ts", "type": "registry:lib", "target": "lib/charts-core/util/format/percentage-format-options.ts" + }, + { + "path": "registry/charts-core/util/build-field.ts", + "type": "registry:lib", + "target": "lib/charts-core/util/build-field.ts" + }, + { + "path": "registry/charts-core/util/get-metric-field-type.ts", + "type": "registry:lib", + "target": "lib/charts-core/util/get-metric-field-type.ts" } ] }, @@ -643,11 +668,6 @@ "type": "registry:lib", "target": "lib/data-fabric-adapter/data-fabric-adapter.ts" }, - { - "path": "registry/data-fabric-adapter/schemas/data-query-response-schema.ts", - "type": "registry:lib", - "target": "lib/data-fabric-adapter/schemas/data-query-response-schema.ts" - }, { "path": "registry/data-fabric-adapter/schemas/query-schema.ts", "type": "registry:lib", @@ -658,11 +678,6 @@ "type": "registry:lib", "target": "lib/data-fabric-adapter/utils/binning-utils.ts" }, - { - "path": "registry/data-fabric-adapter/utils/chart-data-mapper.ts", - "type": "registry:lib", - "target": "lib/data-fabric-adapter/utils/chart-data-mapper.ts" - }, { "path": "registry/data-fabric-adapter/utils/fetch-date-binned-data.ts", "type": "registry:lib", @@ -700,6 +715,122 @@ } ] }, + { + "name": "insights-adapter", + "type": "registry:lib", + "title": "Insights Adapter", + "description": "DataAdapter implementation that talks to the UiPath Insights standalone-query API. Pair with any chart from charts-core.", + "dependencies": [ + "@tanstack/react-query@^5.90.0", + "@ts-rest/core@^3.53.0-rc.1", + "@types/luxon", + "luxon", + "zod" + ], + "registryDependencies": ["@uipath/charts-core"], + "files": [ + { + "path": "registry/insights-adapter/adapter.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/adapter.ts" + }, + { + "path": "registry/insights-adapter/chart-adapters/bar.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/chart-adapters/bar.ts" + }, + { + "path": "registry/insights-adapter/chart-adapters/distribution.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/chart-adapters/distribution.ts" + }, + { + "path": "registry/insights-adapter/chart-adapters/kpi.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/chart-adapters/kpi.ts" + }, + { + "path": "registry/insights-adapter/chart-adapters/line.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/chart-adapters/line.ts" + }, + { + "path": "registry/insights-adapter/chart-adapters/multi-line.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/chart-adapters/multi-line.ts" + }, + { + "path": "registry/insights-adapter/chart-adapters/table.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/chart-adapters/table.ts" + }, + { + "path": "registry/insights-adapter/contract.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/contract.ts" + }, + { + "path": "registry/insights-adapter/insights-adapter.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/insights-adapter.ts" + }, + { + "path": "registry/insights-adapter/schemas/aggregate-fragment-schema.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/schemas/aggregate-fragment-schema.ts" + }, + { + "path": "registry/insights-adapter/schemas/filter-fragment-schema.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/schemas/filter-fragment-schema.ts" + }, + { + "path": "registry/insights-adapter/schemas/filter-request-schema.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/schemas/filter-request-schema.ts" + }, + { + "path": "registry/insights-adapter/schemas/query-schema.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/schemas/query-schema.ts" + }, + { + "path": "registry/insights-adapter/utils/assert-configuration-supported.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/utils/assert-configuration-supported.ts" + }, + { + "path": "registry/insights-adapter/utils/binning.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/utils/binning.ts" + }, + { + "path": "registry/insights-adapter/utils/filter-request.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/utils/filter-request.ts" + }, + { + "path": "registry/insights-adapter/utils/metric-aggregate.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/utils/metric-aggregate.ts" + }, + { + "path": "registry/insights-adapter/utils/query-options.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/utils/query-options.ts" + }, + { + "path": "registry/insights-adapter/utils/query.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/utils/query.ts" + }, + { + "path": "registry/insights-adapter/utils/throw-error.ts", + "type": "registry:lib", + "target": "lib/insights-adapter/utils/throw-error.ts" + } + ] + }, { "name": "line-chart", "type": "registry:ui", diff --git a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts index 48948be51..411ce4341 100644 --- a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts +++ b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric-table/table-data-model.ts @@ -1,4 +1,5 @@ import { assert } from "@/lib/asserts/assert"; +import { buildField } from "@/lib/charts-core"; import { type Entity, type EntityField, @@ -8,11 +9,9 @@ import { export function buildTableDataModel(entity: Entity) { return { id: entity.id, - fields: entity.fields.map((field) => ({ - id: field.name, - display: field.name, - type: mapFieldType(field.dataType), - })), + fields: entity.fields.map((field) => + buildField(field.name, mapFieldType(field.dataType)), + ), }; } @@ -29,11 +28,7 @@ export function buildMultiEntityDataModel( field != null, `buildMultiEntityDataModel: dimension "${d}" is not present in qualified fields. Caller must validate dimensions first.`, ); - return { - id: d, - display: d, - type: mapFieldType(field.dataType), - }; + return buildField(d, mapFieldType(field.dataType)); }), }; } diff --git a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts index acfe7ce24..2a002e278 100644 --- a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts +++ b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/util/chart-helpers.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { - Aggregation, + type AggregationKind, + buildField, type ChartDataModel, type DataModelField, + type DataModelFieldType, type DataModelMetric, - type DimensionType, } from "@/lib/charts-core"; import { type Entity, @@ -21,52 +22,58 @@ import { fail, ok, type ResolverResult } from "./resolver-result"; // the server respects what we send. The `field` reference still needs to // be qualified for the server to resolve the correct entity. export function buildMetricEntry(metric: ResolvedMetric): DataModelMetric { - const aliasField = metric.field.replaceAll(".", "_"); + const aliasField = metric.argument.id.replaceAll(".", "_"); return { - id: `${metric.aggregation.kind}_${aliasField}`, + id: `${metric.aggregation}_${aliasField}`, display: metric.display, - aggregation: metric.aggregation, + expression: { + type: "aggregate", + aggregation: metric.aggregation, + argument: metric.argument, + }, }; } -interface BuildDataModelInput { +interface BuildDataModelInput { id: string; dimension: string; dimensionType: T; metric: ResolvedMetric; } -export function buildDataModel({ +export function buildDataModel({ id, dimension, dimensionType, metric, -}: BuildDataModelInput): ChartDataModel { +}: BuildDataModelInput): ChartDataModel< + Extract +> { return { id, - dimensions: [{ id: dimension, type: dimensionType }], + dimensions: [buildField(dimension, dimensionType)], metrics: [buildMetricEntry(metric)], }; } -interface BuildMultiMetricDataModelInput { +interface BuildMultiMetricDataModelInput { id: string; dimension: string; dimensionType: T; metrics: ResolvedMetric[]; } -export function buildMultiMetricDataModel({ +export function buildMultiMetricDataModel({ id, dimension, dimensionType, metrics, }: BuildMultiMetricDataModelInput): ChartDataModel< - DataModelField & { type: T } + Extract > { return { id, - dimensions: [{ id: dimension, type: dimensionType }], + dimensions: [buildField(dimension, dimensionType)], metrics: metrics.map((m) => buildMetricEntry(m)), }; } @@ -97,68 +104,77 @@ export const metricSchema = z export type MetricInput = z.infer; -export function formatAggregation(aggregation: Aggregation): string { - switch (aggregation.kind) { - case "count": +export function formatAggregation(aggregation: AggregationKind): string { + switch (aggregation) { + case "COUNT": return "Count"; - case "sum": + case "SUM": return "Sum"; - case "avg": + case "AVERAGE": return "Avg"; - case "min": + case "MIN": return "Min"; - case "max": + case "MAX": return "Max"; + case "ANY": + return "Any"; + case "DISTINCT_COUNT": + return "Distinct count"; + case "PERCENTAGE": + return "Percentage"; + case "PERCENTILE": + return "Percentile"; + case "MEDIAN": + return "Median"; } } -function metricInputToAggregation( - input: MetricInput, - field: string, -): Aggregation { +function metricInputToAggregateType(input: MetricInput): AggregationKind { switch (input.aggregation) { case "COUNT": - return Aggregation.count(field); + return "COUNT"; case "SUM": - return Aggregation.sum(field); + return "SUM"; case "AVG": - return Aggregation.avg(field); + return "AVERAGE"; case "MIN": - return Aggregation.min(field); + return "MIN"; case "MAX": - return Aggregation.max(field); + return "MAX"; } } -export interface ResolvedDimension { +export interface ResolvedDimension< + T extends DataModelFieldType = DataModelFieldType, +> { id: string; type: T; } export interface ResolvedMetric { - field: string; - aggregation: Aggregation; + argument: DataModelField; + aggregation: AggregationKind; display: string; } export function dedupeMetrics(metrics: ResolvedMetric[]): ResolvedMetric[] { const seen = new Set(); return metrics.filter((m) => { - const key = `${m.aggregation.kind}|${m.field}`; + const key = `${m.aggregation}|${m.argument.id}`; if (seen.has(key)) return false; seen.add(key); return true; }); } -function isAllowedDimensionType( - value: DimensionType, +function isAllowedFieldType( + value: DataModelFieldType, allowed: ReadonlyArray, ): value is T { - return (allowed as readonly DimensionType[]).includes(value); + return (allowed as readonly DataModelFieldType[]).includes(value); } -export function resolveSingleDimension( +export function resolveSingleDimension( entity: Entity, dimension: string, allowed: ReadonlyArray, @@ -172,7 +188,7 @@ export function resolveSingleDimension( }); } const type = mapFieldType(field.dataType); - if (!isAllowedDimensionType(type, allowed)) { + if (!isAllowedFieldType(type, allowed)) { return fail({ reason: "wrong_dimension_type_in_entity", field: dimension, @@ -184,7 +200,7 @@ export function resolveSingleDimension( return ok({ id: field.name, type }); } -export function resolveMultiDimension( +export function resolveMultiDimension( primaryEntity: string, dimension: string, qualifiedFields: Map, @@ -201,7 +217,7 @@ export function resolveMultiDimension( }); } const type = mapFieldType(field.dataType); - if (!isAllowedDimensionType(type, allowed)) { + if (!isAllowedFieldType(type, allowed)) { return fail({ reason: "wrong_dimension_type_in_joined", field: qualified, @@ -221,16 +237,18 @@ export function resolveSingleMetric( metric?.field && entity.fields.some((f) => f.name === metric.field) ? metric.field : null; - const field = userField ?? pickCountField(entity); - if (!field) { + const fieldName = userField ?? pickCountField(entity); + if (!fieldName) { return fail({ reason: "no_countable_in_entity", entity: entity.name, }); } + const entityField = entity.fields.find((f) => f.name === fieldName); + const type = entityField ? mapFieldType(entityField.dataType) : "string"; return ok({ - field, - aggregation: Aggregation.count(field), + argument: buildField(fieldName, type), + aggregation: "COUNT", display: userField ? `Count of ${userField}` : "Count", }); } @@ -258,9 +276,9 @@ export function resolveSingleMetric( aggregation: metric.aggregation, }); } - const aggregation = metricInputToAggregation(metric, field.name); + const aggregation = metricInputToAggregateType(metric); return ok({ - field: field.name, + argument: buildField(field.name, "numeric"), aggregation, display: `${formatAggregation(aggregation)} of ${field.name}`, }); @@ -287,17 +305,19 @@ export function resolveMultiMetric( : `${primaryEntity}.${metric.field}`; if (qualifiedFields.has(qualified)) userField = qualified; } - const field = + const fieldName = userField ?? pickCountFieldQualified(primaryEntity, qualifiedFields); - if (!field) { + if (!fieldName) { return fail({ reason: "no_countable_in_joined", primary: primaryEntity, }); } + const entityField = qualifiedFields.get(fieldName); + const type = entityField ? mapFieldType(entityField.dataType) : "string"; return ok({ - field, - aggregation: Aggregation.count(field), + argument: buildField(fieldName, type), + aggregation: "COUNT", display: userField ? `Count of ${unqualifiedDisplay(userField)}` : "Count", @@ -328,9 +348,9 @@ export function resolveMultiMetric( aggregation: metric.aggregation, }); } - const aggregation = metricInputToAggregation(metric, qualified); + const aggregation = metricInputToAggregateType(metric); return ok({ - field: qualified, + argument: buildField(qualified, "numeric"), aggregation, display: `${formatAggregation(aggregation)} of ${unqualifiedDisplay(qualified)}`, }); diff --git a/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx b/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx index 7c0396c5a..f2ad02bab 100644 --- a/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx +++ b/apps/apollo-vertex/registry/bar-chart/bar-chart-with-adapter.tsx @@ -95,7 +95,7 @@ function BarChartResolver({ for (const m of metrics) { const v = assertNumberOrNull(row[m.id], "Metric value") ?? 0; values[m.id] = v; - formattedValues[m.id] = formatMetricValue(language, v, m.aggregation); + formattedValues[m.id] = formatMetricValue(language, v, m.expression); const total = totalsByMetricId[m.id] ?? 0; formattedPercents[m.id] = total === 0 ? "" : formatPercentage(language, v / total); diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/chart-data-mapper.ts b/apps/apollo-vertex/registry/charts-core/chart-data-mapper.ts similarity index 57% rename from apps/apollo-vertex/registry/data-fabric-adapter/utils/chart-data-mapper.ts rename to apps/apollo-vertex/registry/charts-core/chart-data-mapper.ts index 1c7c49769..59c3e5b8d 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/chart-data-mapper.ts +++ b/apps/apollo-vertex/registry/charts-core/chart-data-mapper.ts @@ -1,9 +1,6 @@ import { assertDefined } from "@/lib/asserts/assert-defined"; -import type { PrimitiveValue } from "@/lib/charts-core"; -import { - type DataQueryResponse, - PrimitiveValueSchema, -} from "../schemas/data-query-response-schema"; +import type { DataQueryResponse } from "./data-query-response-schema"; +import type { PrimitiveValue } from "./models/primitive-value"; interface ChartDataMappingOptions { dimensions: string[]; @@ -30,29 +27,29 @@ export function mapResponseToChartData({ .values; }); - const metricValues = metrics?.map((metric) => { + const metricValues = metrics.map((metric) => { return assertDefined(data[metric], `Metric data: ${metric}`).values; }); return Array.from({ length: rowCount }, (_, rowIdx) => { const entries: Array = [ ...dimensions.map( - (dimension, dimensionIdx): readonly [string, PrimitiveValue] => { - const value = PrimitiveValueSchema.parse( - assertDefined( - dimensionValues[dimensionIdx], - `Dimension ${dimensionIdx}`, - )[rowIdx], - ); - return [dimension, value]; - }, + (dimension, dimensionIdx): readonly [string, PrimitiveValue] => [ + dimension, + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) `DataFabricQueryResponse` was validated upstream + assertDefined( + dimensionValues[dimensionIdx], + `Dimension ${dimensionIdx}`, + )[rowIdx] as PrimitiveValue, + ], ), - ...metrics.map((metric, metricIdx): readonly [string, PrimitiveValue] => { - const value = PrimitiveValueSchema.parse( - assertDefined(metricValues[metricIdx], `Metric ${metricIdx}`)[rowIdx], - ); - return [metric, value]; - }), + ...metrics.map((metric, metricIdx): readonly [string, PrimitiveValue] => [ + metric, + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) `DataFabricQueryResponse` was validated upstream + assertDefined(metricValues[metricIdx], `Metric ${metricIdx}`)[ + rowIdx + ] as PrimitiveValue, + ]), ]; return Object.fromEntries(entries); diff --git a/apps/apollo-vertex/registry/charts-core/chart-models.ts b/apps/apollo-vertex/registry/charts-core/chart-models.ts index d0225b62b..135c6af62 100644 --- a/apps/apollo-vertex/registry/charts-core/chart-models.ts +++ b/apps/apollo-vertex/registry/charts-core/chart-models.ts @@ -1,18 +1,27 @@ import type { Interval } from "luxon"; -import type { Aggregation } from "./models/aggregation"; +import type { MetricExpression } from "./models/expression"; +import type { + DatetimeModelField, + DataModelField, + DataModelFieldType, + NumericOrDatetimeModelField, + StringModelField, +} from "./models/field"; import type { PrimitiveValue } from "./models/primitive-value"; -export type DimensionType = "numeric" | "string" | "boolean" | "datetime"; - -export interface DataModelField { - id: string; - type: DimensionType; -} +export type { + DataModelField, + DataModelFieldType, + DatetimeModelField, + NumericOrDatetimeModelField, + StringModelField, +}; +export type DimensionType = DataModelFieldType; export interface DataModelMetric { id: string; display: string; - aggregation: Aggregation; + expression: MetricExpression; } export interface ChartDataModel< @@ -28,12 +37,6 @@ export interface KpiDataModel { metrics: DataModelMetric[]; } -export type DatetimeModelField = DataModelField & { type: "datetime" }; -export type NumericOrDatetimeModelField = DataModelField & { - type: "numeric" | "datetime"; -}; -export type StringModelField = DataModelField & { type: "string" }; - export interface KpiChartData<_TMetaData = unknown> { data: number | number[]; labels?: string[]; diff --git a/apps/apollo-vertex/registry/charts-core/charts-core.ts b/apps/apollo-vertex/registry/charts-core/charts-core.ts index 033f063c6..afeb6fcdf 100644 --- a/apps/apollo-vertex/registry/charts-core/charts-core.ts +++ b/apps/apollo-vertex/registry/charts-core/charts-core.ts @@ -1,23 +1,29 @@ +export { mapResponseToChartData } from "./chart-data-mapper"; export { ChartLoadingBoundary } from "./chart-loading-boundary"; export type { BarChartData, ChartDataModel, - DataModelField, - DataModelMetric, DatetimeModelField, DimensionType, DistributionChartData, + DataModelField, + DataModelFieldType, KpiChartData, KpiDataModel, LineChartData, + DataModelMetric, MultiLineChartData, NumericOrDatetimeModelField, StringModelField, TableChartData, } from "./chart-models"; export type { DataAdapter } from "./data-adapter"; +export { + type DataQueryResponse, + DataQueryResponseSchema, + PrimitiveValueSchema, +} from "./data-query-response-schema"; export { mapConfigFilterToFilterValues } from "./map-filter-config"; -export { Aggregation, type AggregationKind } from "./models/aggregation"; export { type BarChartConfiguration, BarChartConfigurationSchema, @@ -49,6 +55,11 @@ export { type TableChartConfiguration, TableChartConfigurationSchema, } from "./models/configurations/table-chart-configuration"; +export type { + DataModelAggregate, + AggregationKind, + MetricExpression, +} from "./models/expression"; export type { ListFilter } from "./models/filter"; export type { FilterValues } from "./models/filter-values"; export { @@ -64,7 +75,9 @@ export { niceDurationNumbers } from "./util/binning/nice-duration-numbers"; export { niceNumbers } from "./util/binning/nice-numbers"; export { dataTypeAlignment } from "./util/data-type-alignment"; export { binLabel } from "./util/format/bin-label"; +export { buildField } from "./util/build-field"; export { format } from "./util/format/format"; export { formatMetricValue } from "./util/format/format-metric-value"; export { formatPercentage } from "./util/format/format-percentage"; export { getChartRange } from "./util/format/get-chart-range"; +export { getMetricFieldType } from "./util/get-metric-field-type"; diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/schemas/data-query-response-schema.ts b/apps/apollo-vertex/registry/charts-core/data-query-response-schema.ts similarity index 100% rename from apps/apollo-vertex/registry/data-fabric-adapter/schemas/data-query-response-schema.ts rename to apps/apollo-vertex/registry/charts-core/data-query-response-schema.ts diff --git a/apps/apollo-vertex/registry/charts-core/models/aggregation.ts b/apps/apollo-vertex/registry/charts-core/models/aggregation.ts deleted file mode 100644 index 34658465a..000000000 --- a/apps/apollo-vertex/registry/charts-core/models/aggregation.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type Aggregation = - | { kind: "count"; field?: string } - | { kind: "sum"; field: string } - | { kind: "avg"; field: string } - | { kind: "min"; field: string } - | { kind: "max"; field: string }; - -export type AggregationKind = Aggregation["kind"]; - -export const Aggregation = { - count: (field?: string): Aggregation => ({ kind: "count", field }), - sum: (field: string): Aggregation => ({ kind: "sum", field }), - avg: (field: string): Aggregation => ({ kind: "avg", field }), - min: (field: string): Aggregation => ({ kind: "min", field }), - max: (field: string): Aggregation => ({ kind: "max", field }), -} as const; diff --git a/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts b/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts index 731022fde..a09e578e0 100644 --- a/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts +++ b/apps/apollo-vertex/registry/charts-core/models/configurations/base-chart-configuration.ts @@ -10,4 +10,5 @@ export const BaseChartConfigurationSchema = z.object({ filters: z.array(FilterValuesSchema).optional(), joins: z.array(JoinConfigSchema).optional(), from: FromConfigSchema.optional(), + filterTableId: z.string().optional(), }); diff --git a/apps/apollo-vertex/registry/charts-core/models/expression.ts b/apps/apollo-vertex/registry/charts-core/models/expression.ts new file mode 100644 index 000000000..90ba6c220 --- /dev/null +++ b/apps/apollo-vertex/registry/charts-core/models/expression.ts @@ -0,0 +1,24 @@ +import type { DataModelField } from "./field"; +import type { FilterValues } from "./filter-values"; + +export type AggregationKind = + | "ANY" + | "DISTINCT_COUNT" + | "COUNT" + | "AVERAGE" + | "MIN" + | "MAX" + | "PERCENTAGE" + | "SUM" + | "PERCENTILE" + | "MEDIAN"; + +export interface DataModelAggregate { + id?: string; + type: "aggregate"; + aggregation: AggregationKind; + argument: DataModelField; + filters?: FilterValues[]; +} + +export type MetricExpression = DataModelAggregate; diff --git a/apps/apollo-vertex/registry/charts-core/models/field.ts b/apps/apollo-vertex/registry/charts-core/models/field.ts new file mode 100644 index 000000000..618a2e396 --- /dev/null +++ b/apps/apollo-vertex/registry/charts-core/models/field.ts @@ -0,0 +1,52 @@ +interface BaseField { + id: string; + display: string; +} + +interface NumericField extends BaseField { + type: "numeric"; +} + +interface StringField extends BaseField { + type: "string"; +} + +interface DateTimeField extends BaseField { + type: "datetime"; +} + +interface PercentageField extends BaseField { + type: "percentage"; +} + +interface DurationField extends BaseField { + type: "duration"; +} + +interface CurrencyField extends BaseField { + type: "currency"; + format?: { currency: string }; +} + +interface BooleanField extends BaseField { + type: "boolean"; + format?: { trueDisplay: string; falseDisplay: string }; +} + +export type DataModelField = + | NumericField + | StringField + | DateTimeField + | PercentageField + | DurationField + | CurrencyField + | BooleanField; + +export type DataModelFieldType = DataModelField["type"]; + +export type DatetimeModelField = Extract; +export type StringModelField = Extract; +export type NumericOrDatetimeModelField = Extract< + DataModelField, + { type: "numeric" | "datetime" } +>; diff --git a/apps/apollo-vertex/registry/charts-core/table-data-model.ts b/apps/apollo-vertex/registry/charts-core/table-data-model.ts index c38ca56fc..273dad673 100644 --- a/apps/apollo-vertex/registry/charts-core/table-data-model.ts +++ b/apps/apollo-vertex/registry/charts-core/table-data-model.ts @@ -1,12 +1,8 @@ -import type { DimensionType } from "./chart-models"; +import type { DataModelField } from "./models/field"; -export interface TableDataModelField { - id: string; - display: string; - type: DimensionType; -} +export type TableDataModelField = DataModelField; export interface TableDataModel { id: string; - fields: Array; + fields: Array; } diff --git a/apps/apollo-vertex/registry/charts-core/util/build-field.ts b/apps/apollo-vertex/registry/charts-core/util/build-field.ts new file mode 100644 index 000000000..e2e488190 --- /dev/null +++ b/apps/apollo-vertex/registry/charts-core/util/build-field.ts @@ -0,0 +1,12 @@ +import type { DataModelField, DataModelFieldType } from "../models/field"; + +export function buildField( + id: string, + type: T, +): Extract; +export function buildField( + id: string, + type: DataModelFieldType, +): DataModelField { + return { id, display: id, type }; +} diff --git a/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts b/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts index 19a0d6d70..eaf465a6d 100644 --- a/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts +++ b/apps/apollo-vertex/registry/charts-core/util/data-type-alignment.ts @@ -1,11 +1,14 @@ -import type { DimensionType } from "../chart-models"; +import type { DataModelFieldType } from "../chart-models"; export function dataTypeAlignment(field: { - type: DimensionType; + type: DataModelFieldType; }): "left" | "right" { switch (field.type) { case "datetime": case "numeric": + case "currency": + case "percentage": + case "duration": return "right"; case "boolean": case "string": diff --git a/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts b/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts index 78ed49cc4..43d0bfceb 100644 --- a/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts +++ b/apps/apollo-vertex/registry/charts-core/util/format/format-metric-value.ts @@ -1,19 +1,34 @@ -import type { Aggregation } from "../../models/aggregation"; +import type { MetricExpression } from "../../models/expression"; +import { getMetricFieldType } from "../get-metric-field-type"; import type { BaseFormatOptions } from "./base-format-options"; import { format } from "./format"; export function formatMetricValue( locale: Intl.LocalesArgument, value: unknown, - aggregation: Aggregation, + expression: MetricExpression, options?: BaseFormatOptions, ): string { - switch (aggregation.kind) { - case "count": - case "sum": - case "avg": - case "min": - case "max": - return format(locale, value, "numeric", options); + const fieldType = getMetricFieldType(expression); + + if (fieldType === "currency" && expression.argument.type === "currency") { + const fmt = expression.argument.format; + return format(locale, value, "currency", { + ...options, + ...(fmt && { currency: fmt.currency }), + }); + } + + if (fieldType === "boolean" && expression.argument.type === "boolean") { + const fmt = expression.argument.format; + return format(locale, value, "boolean", { + ...options, + ...(fmt && { + trueLabel: fmt.trueDisplay, + falseLabel: fmt.falseDisplay, + }), + }); } + + return format(locale, value, fieldType, options); } diff --git a/apps/apollo-vertex/registry/charts-core/util/format/format.ts b/apps/apollo-vertex/registry/charts-core/util/format/format.ts index 5a0b039c4..50741913e 100644 --- a/apps/apollo-vertex/registry/charts-core/util/format/format.ts +++ b/apps/apollo-vertex/registry/charts-core/util/format/format.ts @@ -13,7 +13,7 @@ import { formatDuration } from "./format-duration"; import { formatNumber } from "./format-number"; import { formatPercentage } from "./format-percentage"; -type FieldType = +type DataModelFieldType = | "string" | "boolean" | "id" @@ -39,7 +39,7 @@ type TypeToFormat = { // Generic-narrowing pattern: each case narrows fieldType to a specific // literal, but TS can't narrow `options: TypeToFormat[TFieldType]` along // with it, so the `as` casts are mechanical and safe. -export const format = ( +export const format = ( locale: Intl.LocalesArgument, value: unknown, fieldType: TFieldType, diff --git a/apps/apollo-vertex/registry/charts-core/util/get-metric-field-type.ts b/apps/apollo-vertex/registry/charts-core/util/get-metric-field-type.ts new file mode 100644 index 000000000..1956d7e71 --- /dev/null +++ b/apps/apollo-vertex/registry/charts-core/util/get-metric-field-type.ts @@ -0,0 +1,22 @@ +import type { MetricExpression } from "../models/expression"; +import type { DataModelFieldType } from "../models/field"; + +export function getMetricFieldType( + expression: MetricExpression, +): DataModelFieldType { + switch (expression.aggregation) { + case "COUNT": + case "DISTINCT_COUNT": + return "numeric"; + case "PERCENTAGE": + return "percentage"; + case "ANY": + case "AVERAGE": + case "MAX": + case "MEDIAN": + case "MIN": + case "PERCENTILE": + case "SUM": + return expression.argument.type; + } +} diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts b/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts index e16313fc9..bc57bf113 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts +++ b/apps/apollo-vertex/registry/data-fabric-adapter/adapter.ts @@ -1,7 +1,11 @@ import { queryOptions } from "@tanstack/react-query"; import { initClient } from "@ts-rest/core"; import { z } from "zod"; -import type { DataAdapter, ListFilter } from "@/lib/charts-core"; +import { + type DataAdapter, + type ListFilter, + PrimitiveValueSchema, +} from "@/lib/charts-core"; import { dataFabricBarChartAdapter } from "./chart-adapters/bar"; import { dataFabricDistributionChartAdapter } from "./chart-adapters/distribution"; import { dataFabricKpiChartAdapter } from "./chart-adapters/kpi"; @@ -9,7 +13,6 @@ import { dataFabricLineChartAdapter } from "./chart-adapters/line"; import { dataFabricMultiLineChartAdapter } from "./chart-adapters/multi-line"; import { dataFabricTableChartAdapter } from "./chart-adapters/table"; import { dataFabricContract } from "./contract"; -import { PrimitiveValueSchema } from "./schemas/data-query-response-schema"; import { dataFabricQuery } from "./utils/query"; interface DataFabricAdapterProps { diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts index 1e1ec1459..9bfa31d5b 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts +++ b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/bar.ts @@ -3,9 +3,9 @@ import { assertDefined } from "@/lib/asserts/assert-defined"; import { type DataAdapter, mapConfigFilterToFilterValues, + mapResponseToChartData, } from "@/lib/charts-core"; import type { DataFabricQueryRequest } from "../schemas/query-schema"; -import { mapResponseToChartData } from "../utils/chart-data-mapper"; import { mapMetricToDataFabricAggregate } from "../utils/metric-aggregate"; import { type DataFabricClient, dataFabricQuery } from "../utils/query"; import { createDataFabricChartQueryOptions } from "../utils/query-options"; diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts index 3c96c09c1..90ba4c827 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts +++ b/apps/apollo-vertex/registry/data-fabric-adapter/chart-adapters/table.ts @@ -1,7 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; -import type { DataAdapter } from "@/lib/charts-core"; -import { mapConfigFilterToFilterValues } from "@/lib/charts-core"; -import { mapResponseToChartData } from "../utils/chart-data-mapper"; +import { + type DataAdapter, + mapConfigFilterToFilterValues, + mapResponseToChartData, +} from "@/lib/charts-core"; import { type DataFabricClient, dataFabricQuery } from "../utils/query"; import { createDataFabricChartQueryOptions } from "../utils/query-options"; import { mapDataFabricResponseToChartData } from "../utils/response-data-mapper"; diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts b/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts index 94e753293..baa731d25 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts +++ b/apps/apollo-vertex/registry/data-fabric-adapter/schemas/query-schema.ts @@ -1,5 +1,9 @@ import { z } from "zod"; -import { FromConfigSchema, JoinConfigSchema } from "@/lib/charts-core"; +import { + FromConfigSchema, + JoinConfigSchema, + PrimitiveValueSchema, +} from "@/lib/charts-core"; const DataFabricSortOptionSchema = z.object({ fieldName: z.string(), @@ -70,7 +74,7 @@ export const DataFabricQueryRequestSchema = z.object({ export const DataFabricQueryResponseSchema = z.object({ totalRecordCount: z.number(), - value: z.array(z.record(z.string(), z.unknown())), + value: z.array(z.record(z.string(), PrimitiveValueSchema)), }); export type DataFabricQueryRequest = z.infer< diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts b/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts index 49dd91c56..4389a769b 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts +++ b/apps/apollo-vertex/registry/data-fabric-adapter/utils/fetch-date-binned-data.ts @@ -16,13 +16,13 @@ import { mapFilterValuesToDataFabricFilterGroup } from "./filter-group"; import type { DataFabricAggregate } from "./metric-aggregate"; import { type DataFabricClient, dataFabricQuery } from "./query"; -type Aggregate = DataFabricAggregate; +type DataModelAggregate = DataFabricAggregate; interface FetchDateBinnedDataOptions { client: DataFabricClient; entityName: string; dimensionId: string; - aggregates: Aggregate[]; + aggregates: DataModelAggregate[]; filters: FilterValues[]; joins?: JoinConfig[]; from?: FromConfig; diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts b/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts index d932a1666..3fa062f44 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts +++ b/apps/apollo-vertex/registry/data-fabric-adapter/utils/metric-aggregate.ts @@ -11,21 +11,32 @@ export interface DataFabricAggregate { export function mapMetricToDataFabricAggregate( metric: DataModelMetric, ): DataFabricAggregate { - const a = metric.aggregation; - switch (a.kind) { - case "count": - return { - function: "COUNT", - field: a.field ?? metric.id, - alias: metric.id, - }; - case "sum": - return { function: "SUM", field: a.field, alias: metric.id }; - case "avg": - return { function: "AVG", field: a.field, alias: metric.id }; - case "min": - return { function: "MIN", field: a.field, alias: metric.id }; - case "max": - return { function: "MAX", field: a.field, alias: metric.id }; + const expr = metric.expression; + if (expr.filters && expr.filters.length > 0) { + throw new Error( + `Data Fabric does not support per-aggregate filters (metric "${metric.id}").`, + ); + } + + const field = expr.argument.id; + switch (expr.aggregation) { + case "COUNT": + return { function: "COUNT", field, alias: metric.id }; + case "SUM": + return { function: "SUM", field, alias: metric.id }; + case "AVERAGE": + return { function: "AVG", field, alias: metric.id }; + case "MIN": + return { function: "MIN", field, alias: metric.id }; + case "MAX": + return { function: "MAX", field, alias: metric.id }; + case "ANY": + case "DISTINCT_COUNT": + case "MEDIAN": + case "PERCENTAGE": + case "PERCENTILE": + throw new Error( + `Data Fabric does not support aggregation "${expr.aggregation}" (metric "${metric.id}").`, + ); } } diff --git a/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts b/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts index 2fd7c05e9..8a226a058 100644 --- a/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts +++ b/apps/apollo-vertex/registry/data-fabric-adapter/utils/response-data-mapper.ts @@ -1,7 +1,4 @@ -import { - type DataQueryResponse, - PrimitiveValueSchema, -} from "../schemas/data-query-response-schema"; +import type { DataQueryResponse } from "@/lib/charts-core"; import type { DataFabricQueryResponse } from "../schemas/query-schema"; export function mapDataFabricResponseToChartData( @@ -19,10 +16,7 @@ export function mapDataFabricResponseToChartData( for (const key of columnKeys) { result[key] = { - values: rows.map((row) => { - const value = row[key]; - return value == null ? null : PrimitiveValueSchema.parse(value); - }), + values: rows.map((row) => row[key] ?? null), stackValues: null, ungrouped: null, }; diff --git a/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx b/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx index a03197475..24a3dd8d5 100644 --- a/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx +++ b/apps/apollo-vertex/registry/distribution-chart/distribution-chart-with-adapter.tsx @@ -98,7 +98,7 @@ function DistributionChartResolver({ data={data} seriesLabel={metric.display} formatValue={(value) => - formatMetricValue(language, value, metric.aggregation) + formatMetricValue(language, value, metric.expression) } /> diff --git a/apps/apollo-vertex/registry/insights-adapter/adapter.ts b/apps/apollo-vertex/registry/insights-adapter/adapter.ts new file mode 100644 index 000000000..7eebc519f --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/adapter.ts @@ -0,0 +1,68 @@ +import { queryOptions } from "@tanstack/react-query"; +import { initClient } from "@ts-rest/core"; +import { z } from "zod"; +import { + type DataAdapter, + type ListFilter, + PrimitiveValueSchema, +} from "@/lib/charts-core"; +import { insightsBarChartAdapter } from "./chart-adapters/bar"; +import { insightsDistributionChartAdapter } from "./chart-adapters/distribution"; +import { insightsKpiChartAdapter } from "./chart-adapters/kpi"; +import { insightsLineChartAdapter } from "./chart-adapters/line"; +import { insightsMultiLineChartAdapter } from "./chart-adapters/multi-line"; +import { insightsTableChartAdapter } from "./chart-adapters/table"; +import { insightsContract } from "./contract"; +import { type InsightsClient, insightsQuery } from "./utils/query"; + +interface InsightsAdapterProps { + baseUrl: string; + accessToken: string; + sourceType: string; +} + +export const insightsAdapter = ({ + baseUrl, + accessToken, + sourceType, +}: InsightsAdapterProps): DataAdapter => { + const client: InsightsClient = initClient(insightsContract, { + baseUrl, + baseHeaders: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + return { + charts: { + table: insightsTableChartAdapter(client, sourceType), + bar: insightsBarChartAdapter(client, sourceType), + distribution: insightsDistributionChartAdapter(client, sourceType), + line: insightsLineChartAdapter(client, sourceType), + multiLine: insightsMultiLineChartAdapter(client, sourceType), + kpi: insightsKpiChartAdapter(client, sourceType), + }, + filters: { + list: (filter: ListFilter) => { + return queryOptions({ + queryKey: [sourceType, filter.field.id] as readonly unknown[], + queryFn: async () => { + const response = await insightsQuery( + client, + sourceType, + { + filters: [], + groupBy: [filter.field.id], + aggregates: [], + }, + "Failed to fetch filter values", + ); + + const values = response[filter.field.id]?.values ?? []; + return z.array(PrimitiveValueSchema).parse(values); + }, + }); + }, + }, + }; +}; diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/bar.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/bar.ts new file mode 100644 index 000000000..38c237571 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/bar.ts @@ -0,0 +1,56 @@ +import { queryOptions } from "@tanstack/react-query"; +import { assertDefined } from "@/lib/asserts/assert-defined"; +import { + type DataAdapter, + mapConfigFilterToFilterValues, + mapResponseToChartData, +} from "@/lib/charts-core"; +import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported"; +import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate"; +import { type InsightsClient, insightsQuery } from "../utils/query"; +import { createInsightsChartQueryOptions } from "../utils/query-options"; + +export const insightsBarChartAdapter = ( + client: InsightsClient, + sourceType: string, +): DataAdapter["charts"]["bar"] => { + return (configuration, dataModel) => { + assertInsightsConfigurationSupported(configuration); + const dimensionIds = configuration.dimensions; + const metrics = configuration.metrics.map((id) => + assertDefined( + dataModel.metrics.find((m) => m.id === id), + `Metric ${id} not found in dataModel`, + ), + ); + const aggregates = metrics.map((m) => mapMetricToInsightsAggregate(m)); + + const requestBody = createInsightsChartQueryOptions({ + groupBy: dimensionIds, + aggregates, + sortBy: null, + filters: (configuration.filters ?? []).map((f) => + mapConfigFilterToFilterValues(f), + ), + filterTableId: configuration.filterTableId, + }); + + return queryOptions({ + queryKey: [sourceType, "bar", JSON.stringify(requestBody)], + queryFn: async () => { + const body = await insightsQuery( + client, + sourceType, + requestBody, + "Failed to fetch bar chart data", + ); + + return mapResponseToChartData({ + data: body, + dimensions: dimensionIds, + metrics: aggregates.map((a) => a.id), + }); + }, + }); + }; +}; diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/distribution.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/distribution.ts new file mode 100644 index 000000000..b27956c26 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/distribution.ts @@ -0,0 +1,168 @@ +import { queryOptions } from "@tanstack/react-query"; +import { DateTime } from "luxon"; +import { z } from "zod"; +import { assert } from "@/lib/asserts/assert"; +import { assertDefined } from "@/lib/asserts/assert-defined"; +import { + type DataAdapter, + type DistributionChartData, + mapConfigFilterToFilterValues, +} from "@/lib/charts-core"; +import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported"; +import { + calculateDatetimeBins, + calculateNumericBins, + findBinCutoffPoints, +} from "../utils/binning"; +import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate"; +import { type InsightsClient, insightsQuery } from "../utils/query"; +import { createInsightsChartQueryOptions } from "../utils/query-options"; + +export const insightsDistributionChartAdapter = ( + client: InsightsClient, + sourceType: string, +): DataAdapter["charts"]["distribution"] => { + return (configuration, dataModel) => { + assertInsightsConfigurationSupported(configuration); + const dimensionId = assertDefined( + configuration.dimensions[0], + "Distribution chart must have at least one dimension", + ); + const dimension = assertDefined( + dataModel.dimensions.find((d) => d.id === dimensionId), + `Dimension ${dimensionId} not found in dataModel`, + ); + assert( + dimension.type === "datetime" || dimension.type === "numeric", + "Dimension type must be datetime or numeric", + ); + + const firstMetricId = assertDefined( + configuration.metrics[0], + "Distribution chart must have at least one metric", + ); + const firstMetric = assertDefined( + dataModel.metrics.find((m) => m.id === firstMetricId), + `Metric ${firstMetricId} not found in dataModel`, + ); + const aggregate = mapMetricToInsightsAggregate(firstMetric); + + const filters = (configuration.filters ?? []).map((f) => + mapConfigFilterToFilterValues(f), + ); + const filterTableId = configuration.filterTableId; + + const minMaxRequest = createInsightsChartQueryOptions({ + groupBy: [], + aggregates: [ + { + id: "min", + expression: { + type: "aggregate", + aggregation: "MIN", + argument: dimensionId, + }, + }, + { + id: "max", + expression: { + type: "aggregate", + aggregation: "MAX", + argument: dimensionId, + }, + }, + ], + sortBy: null, + filters, + filterTableId, + }); + + return queryOptions< + DistributionChartData, + Error, + DistributionChartData, + string[] + >({ + queryKey: [ + sourceType, + "distribution", + JSON.stringify(minMaxRequest), + JSON.stringify(aggregate), + ], + queryFn: async () => { + const response = await insightsQuery( + client, + sourceType, + minMaxRequest, + "Failed to fetch min/max", + ); + + const bins = + dimension.type === "datetime" + ? (() => { + const minStr = z + .string() + .nullable() + .parse(response.min?.values?.[0] ?? null); + const maxStr = z + .string() + .nullable() + .parse(response.max?.values?.[0] ?? null); + if (!minStr || !maxStr) { + return null; + } + return calculateDatetimeBins({ + min: DateTime.fromISO(minStr, { zone: "utc" }), + max: DateTime.fromISO(maxStr, { zone: "utc" }), + }); + })() + : (() => { + const minNum = z + .number() + .nullable() + .parse(response.min?.values?.[0] ?? null); + const maxNum = z + .number() + .nullable() + .parse(response.max?.values?.[0] ?? null); + if (minNum == null || maxNum == null) { + return null; + } + return calculateNumericBins({ min: minNum, max: maxNum }); + })(); + + if (!bins) { + return { values: [], bins: [] }; + } + + const cutoffPoints = findBinCutoffPoints(bins); + if (cutoffPoints.length === 0) { + return { values: [], bins: [] }; + } + + const binsRequest = createInsightsChartQueryOptions({ + groupBy: [], + aggregates: [aggregate], + sortBy: null, + filters, + filterTableId, + binning: { bins: cutoffPoints, dimension: dimensionId }, + }); + + const dataResponse = await insightsQuery( + client, + sourceType, + binsRequest, + "Failed to fetch distribution data", + ); + + const values = + dataResponse[aggregate.id]?.values?.map( + (value) => z.number().nullable().parse(value) ?? 0, + ) ?? []; + + return { values, bins }; + }, + }); + }; +}; diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/kpi.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/kpi.ts new file mode 100644 index 000000000..8315ef741 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/kpi.ts @@ -0,0 +1,60 @@ +import { queryOptions } from "@tanstack/react-query"; +import { z } from "zod"; +import { assertDefined } from "@/lib/asserts/assert-defined"; +import { + type DataAdapter, + type KpiChartData, + mapConfigFilterToFilterValues, +} from "@/lib/charts-core"; +import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported"; +import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate"; +import { type InsightsClient, insightsQuery } from "../utils/query"; +import { createInsightsChartQueryOptions } from "../utils/query-options"; + +export const insightsKpiChartAdapter = ( + client: InsightsClient, + sourceType: string, +): DataAdapter["charts"]["kpi"] => { + return (configuration, dataModel) => { + assertInsightsConfigurationSupported(configuration); + const firstMetricId = assertDefined( + configuration.metrics[0], + "KPI chart must have at least one metric", + ); + const firstMetric = assertDefined( + dataModel.metrics.find((m) => m.id === firstMetricId), + `Metric ${firstMetricId} not found in dataModel`, + ); + const aggregate = mapMetricToInsightsAggregate(firstMetric); + + const requestBody = createInsightsChartQueryOptions({ + groupBy: [], + aggregates: [aggregate], + sortBy: null, + filters: (configuration.filters ?? []).map((f) => + mapConfigFilterToFilterValues(f), + ), + filterTableId: configuration.filterTableId, + }); + + return queryOptions({ + queryKey: [sourceType, "kpi", JSON.stringify(requestBody)], + queryFn: async () => { + const body = await insightsQuery( + client, + sourceType, + requestBody, + "Failed to fetch KPI data", + ); + + const value = + z + .number() + .nullable() + .parse(body[aggregate.id]?.values?.[0] ?? null) ?? 0; + + return { data: [value], labels: [] }; + }, + }); + }; +}; diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/line.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/line.ts new file mode 100644 index 000000000..e14d16959 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/line.ts @@ -0,0 +1,138 @@ +import { queryOptions } from "@tanstack/react-query"; +import { DateTime } from "luxon"; +import { z } from "zod"; +import { assert } from "@/lib/asserts/assert"; +import { assertDefined } from "@/lib/asserts/assert-defined"; +import { + type DataAdapter, + type LineChartData, + mapConfigFilterToFilterValues, +} from "@/lib/charts-core"; +import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported"; +import { calculateDatetimeBins, findBinCutoffPoints } from "../utils/binning"; +import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate"; +import { type InsightsClient, insightsQuery } from "../utils/query"; +import { createInsightsChartQueryOptions } from "../utils/query-options"; + +export const insightsLineChartAdapter = ( + client: InsightsClient, + sourceType: string, +): DataAdapter["charts"]["line"] => { + return (configuration, dataModel) => { + assertInsightsConfigurationSupported(configuration); + const firstDimensionId = assertDefined( + configuration.dimensions[0], + "Line chart must have at least one dimension", + ); + const firstDimension = assertDefined( + dataModel.dimensions.find((d) => d.id === firstDimensionId), + `Dimension ${firstDimensionId} not found in dataModel`, + ); + const firstMetricId = assertDefined( + configuration.metrics[0], + "Line chart must have at least one metric", + ); + const firstMetric = assertDefined( + dataModel.metrics.find((m) => m.id === firstMetricId), + `Metric ${firstMetricId} not found in dataModel`, + ); + + assert( + firstDimension.type === "datetime", + "Dimension type must be datetime", + ); + + const aggregate = mapMetricToInsightsAggregate(firstMetric); + const filters = (configuration.filters ?? []).map((f) => + mapConfigFilterToFilterValues(f), + ); + const filterTableId = configuration.filterTableId; + + const minMaxRequest = createInsightsChartQueryOptions({ + groupBy: [], + aggregates: [ + { + id: "min", + expression: { + type: "aggregate", + aggregation: "MIN", + argument: firstDimension.id, + }, + }, + { + id: "max", + expression: { + type: "aggregate", + aggregation: "MAX", + argument: firstDimension.id, + }, + }, + ], + sortBy: null, + filters, + filterTableId, + }); + + return queryOptions({ + queryKey: [ + sourceType, + "line", + JSON.stringify(minMaxRequest), + JSON.stringify(aggregate), + ], + queryFn: async () => { + const response = await insightsQuery( + client, + sourceType, + minMaxRequest, + "Failed to fetch min/max", + ); + + const minStr = z + .string() + .nullable() + .parse(response.min?.values?.[0] ?? null); + const maxStr = z + .string() + .nullable() + .parse(response.max?.values?.[0] ?? null); + if (!minStr || !maxStr) { + return { values: [], bins: [] }; + } + + const min = DateTime.fromISO(minStr, { zone: "utc" }); + const max = DateTime.fromISO(maxStr, { zone: "utc" }); + + const bins = calculateDatetimeBins({ min, max }); + + const cutoffPoints = findBinCutoffPoints(bins); + if (cutoffPoints.length === 0) { + return { values: [], bins: [] }; + } + + const binsRequest = createInsightsChartQueryOptions({ + groupBy: [], + aggregates: [aggregate], + sortBy: null, + filters, + filterTableId, + binning: { bins: cutoffPoints, dimension: firstDimension.id }, + }); + + const dataResponse = await insightsQuery( + client, + sourceType, + binsRequest, + "Failed to fetch line chart data", + ); + + const values = + dataResponse[aggregate.id]?.values?.map( + (value) => z.number().nullable().parse(value) ?? 0, + ) ?? []; + + return { values, bins }; + }, + }); + }; +}; diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/multi-line.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/multi-line.ts new file mode 100644 index 000000000..a0849b08c --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/multi-line.ts @@ -0,0 +1,150 @@ +import { queryOptions } from "@tanstack/react-query"; +import { DateTime } from "luxon"; +import { z } from "zod"; +import { assert } from "@/lib/asserts/assert"; +import { assertDefined } from "@/lib/asserts/assert-defined"; +import { + type DataAdapter, + mapConfigFilterToFilterValues, + type MultiLineChartData, +} from "@/lib/charts-core"; +import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported"; +import { calculateDatetimeBins, findBinCutoffPoints } from "../utils/binning"; +import { mapMetricToInsightsAggregate } from "../utils/metric-aggregate"; +import { type InsightsClient, insightsQuery } from "../utils/query"; +import { createInsightsChartQueryOptions } from "../utils/query-options"; + +export const insightsMultiLineChartAdapter = ( + client: InsightsClient, + sourceType: string, +): DataAdapter["charts"]["multiLine"] => { + return (configuration, dataModel) => { + assertInsightsConfigurationSupported(configuration); + const firstDimensionId = assertDefined( + configuration.dimensions[0], + "Multi line chart must have at least one dimension", + ); + const firstDimension = assertDefined( + dataModel.dimensions.find((d) => d.id === firstDimensionId), + `Dimension ${firstDimensionId} not found in dataModel`, + ); + + assert( + firstDimension.type === "datetime", + "Dimension type must be datetime", + ); + + const metrics = configuration.metrics.map((id) => + assertDefined( + dataModel.metrics.find((m) => m.id === id), + `Metric ${id} not found in dataModel`, + ), + ); + const aggregates = metrics.map((m) => mapMetricToInsightsAggregate(m)); + const filters = (configuration.filters ?? []).map((f) => + mapConfigFilterToFilterValues(f), + ); + const filterTableId = configuration.filterTableId; + + const minMaxRequest = createInsightsChartQueryOptions({ + groupBy: [], + aggregates: [ + { + id: "min", + expression: { + type: "aggregate", + aggregation: "MIN", + argument: firstDimension.id, + }, + }, + { + id: "max", + expression: { + type: "aggregate", + aggregation: "MAX", + argument: firstDimension.id, + }, + }, + ], + sortBy: null, + filters, + filterTableId, + }); + + return queryOptions< + MultiLineChartData, + Error, + MultiLineChartData, + string[] + >({ + queryKey: [ + sourceType, + "multi-line", + JSON.stringify(minMaxRequest), + ...aggregates.map((a) => JSON.stringify(a)), + ], + queryFn: async () => { + const response = await insightsQuery( + client, + sourceType, + minMaxRequest, + "Failed to fetch min/max", + ); + + const minStr = z + .string() + .nullable() + .parse(response.min?.values?.[0] ?? null); + const maxStr = z + .string() + .nullable() + .parse(response.max?.values?.[0] ?? null); + if (!minStr || !maxStr) { + return { seriesByMetricId: {}, bins: [] }; + } + + const min = DateTime.fromISO(minStr, { zone: "utc" }); + const max = DateTime.fromISO(maxStr, { zone: "utc" }); + + const bins = calculateDatetimeBins({ min, max }); + + const cutoffPoints = findBinCutoffPoints(bins); + if (cutoffPoints.length === 0) { + return { seriesByMetricId: {}, bins: [] }; + } + + const results = await Promise.all( + aggregates.map((agg) => + insightsQuery( + client, + sourceType, + createInsightsChartQueryOptions({ + groupBy: [], + aggregates: [agg], + sortBy: null, + filters, + filterTableId, + binning: { bins: cutoffPoints, dimension: firstDimension.id }, + }), + "Failed to fetch multi-line chart data", + ), + ), + ); + + const seriesByMetricId: Record = {}; + for (const [idx, agg] of aggregates.entries()) { + const dataResponse = assertDefined( + results[idx], + "Missing results for metric", + ); + seriesByMetricId[agg.id] = + dataResponse[agg.id]?.values?.map( + (value) => z.number().nullable().parse(value) ?? 0, + ) ?? []; + } + + return { seriesByMetricId, bins }; + }, + }); + }; +}; diff --git a/apps/apollo-vertex/registry/insights-adapter/chart-adapters/table.ts b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/table.ts new file mode 100644 index 000000000..c0dffa776 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/chart-adapters/table.ts @@ -0,0 +1,44 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + type DataAdapter, + mapConfigFilterToFilterValues, + mapResponseToChartData, +} from "@/lib/charts-core"; +import { assertInsightsConfigurationSupported } from "../utils/assert-configuration-supported"; +import { type InsightsClient, insightsQuery } from "../utils/query"; +import { createInsightsChartQueryOptions } from "../utils/query-options"; + +export const insightsTableChartAdapter = ( + client: InsightsClient, + sourceType: string, +): DataAdapter["charts"]["table"] => { + return (configuration, _dataModel, state) => { + assertInsightsConfigurationSupported(configuration); + const dimensionIds = configuration.dimensions; + const requestBody = createInsightsChartQueryOptions({ + groupBy: dimensionIds, + sortBy: state.sortBy, + filters: (configuration.filters ?? []).map((f) => + mapConfigFilterToFilterValues(f), + ), + filterTableId: configuration.filterTableId, + }); + + return queryOptions({ + queryKey: [sourceType, "table", JSON.stringify(requestBody)], + queryFn: async () => { + const body = await insightsQuery( + client, + sourceType, + requestBody, + "Failed to fetch table data", + ); + + return mapResponseToChartData({ + data: body, + dimensions: dimensionIds, + }); + }, + }); + }; +}; diff --git a/apps/apollo-vertex/registry/insights-adapter/contract.ts b/apps/apollo-vertex/registry/insights-adapter/contract.ts new file mode 100644 index 000000000..8d6d789e8 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/contract.ts @@ -0,0 +1,28 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; +import { DataQueryResponseSchema } from "@/lib/charts-core"; +import { InsightsQueryRequestSchema } from "./schemas/query-schema"; + +const c = initContract(); + +export const insightsContract = c.router( + { + query: { + method: "POST", + path: "/standalone-query/:sourceType", + responses: { + 200: DataQueryResponseSchema, + 400: z.object({ + error: z.string(), + }), + }, + pathParams: z.object({ + sourceType: z.string(), + }), + body: InsightsQueryRequestSchema, + }, + }, + { + strictStatusCodes: true, + }, +); diff --git a/apps/apollo-vertex/registry/insights-adapter/insights-adapter.ts b/apps/apollo-vertex/registry/insights-adapter/insights-adapter.ts new file mode 100644 index 000000000..3ae88e55a --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/insights-adapter.ts @@ -0,0 +1,2 @@ +export { insightsAdapter } from "./adapter"; +export { insightsContract } from "./contract"; diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/aggregate-fragment-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/aggregate-fragment-schema.ts new file mode 100644 index 000000000..0dc3e8733 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/schemas/aggregate-fragment-schema.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; + +const AggregationKind = z.enum([ + "ANY", + "DISTINCT_COUNT", + "COUNT", + "CASE_AVERAGE", + "AVERAGE", + "MIN", + "MAX", + "PERCENTAGE", + "SUM", + "MEDIAN", + "PERCENTILE", +]); + +const AggregateSchema = z.object({ + type: z.literal("aggregate"), + aggregation: AggregationKind, + argument: z.string(), + filters: z.array(z.any()).optional(), + percentileRank: z + .object({ + kind: z.literal("Constant"), + percentileRank: z.number(), + }) + .optional(), +}); + +const NumericConstantSchema = z.object({ + type: z.literal("constant"), + value: z.number(), + dataType: z.enum(["currency", "numeric", "percentage", "duration"]), +}); +const StringConstantSchema = z.object({ + type: z.literal("constant"), + value: z.string(), + dataType: z.enum(["nominal", "datetime"]), +}); + +const BooleanConstantSchema = z.object({ + type: z.literal("constant"), + value: z.boolean(), + dataType: z.literal("boolean"), +}); + +const ConstantSchema = z.union([ + NumericConstantSchema, + StringConstantSchema, + BooleanConstantSchema, +]); + +const OperandFragmentSchema: z.ZodType = z.lazy(() => + z.union([ + AggregateSchema, + ConstantSchema, + OperatorSchema, + ReferenceSchema, + FunctionSchema, + ]), +); + +const OperatorSchema = z.object({ + type: z.literal("operator"), + operation: z.enum([ + "divide", + "percentage", + "multiply", + "subtract", + "add", + "eq", + "ne", + "gt", + "ge", + "lt", + "le", + "and", + "or", + ]), + left: OperandFragmentSchema, + right: OperandFragmentSchema, +}); + +const ReferenceSchema = z.object({ + type: z.literal("reference"), + reference: z.string(), +}); + +const IsNullNotNullFunctionSchema = z.object({ + type: z.literal("function"), + function: z.union([z.literal("isnull"), z.literal("isnotnull")]), + operand: OperandFragmentSchema, +}); + +const IfThenFunctionSchema = z.object({ + type: z.literal("function"), + function: z.literal("if"), + guard: OperandFragmentSchema, + whenTrue: OperandFragmentSchema, + whenFalse: OperandFragmentSchema.optional(), +}); + +const NotFunctionSchema = z.object({ + type: z.literal("function"), + function: z.literal("not"), + operand: OperandFragmentSchema, +}); + +const LeftRightFunctionSchema = z.object({ + type: z.literal("function"), + function: z.enum(["left", "right"]), + expression: OperandFragmentSchema, + length: OperandFragmentSchema, +}); + +const SubstringFunctionSchema = z.object({ + type: z.literal("function"), + function: z.literal("substring"), + expression: OperandFragmentSchema, + start: OperandFragmentSchema, + length: OperandFragmentSchema, +}); + +const InFunctionSchema = z.object({ + type: z.literal("function"), + function: z.literal("in"), + value: OperandFragmentSchema, + list: z.array(OperandFragmentSchema), +}); + +const ConcatFunctionSchema = z.object({ + type: z.literal("function"), + function: z.literal("concat"), + list: z.array(OperandFragmentSchema), +}); + +const CoalesceFunctionSchema = z.object({ + type: z.literal("function"), + function: z.literal("coalesce"), + list: z.array(OperandFragmentSchema), +}); + +const FunctionSchema = z.union([ + IsNullNotNullFunctionSchema, + NotFunctionSchema, + IfThenFunctionSchema, + SubstringFunctionSchema, + LeftRightFunctionSchema, + InFunctionSchema, + ConcatFunctionSchema, + CoalesceFunctionSchema, +]); + +const ExpressionFragmentSchema = z.union([ + AggregateSchema, + OperatorSchema, + ConstantSchema, + ReferenceSchema, + FunctionSchema, +]); + +export const AggregateFragmentSchema = z.object({ + id: z.string(), + expression: ExpressionFragmentSchema, +}); + +export type AggregateFragment = z.infer; diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/filter-fragment-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-fragment-schema.ts new file mode 100644 index 000000000..95b8a754d --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-fragment-schema.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +const PrimitiveValueSchema = z.union([ + z.string(), + z.boolean(), + z.number(), + z.null(), +]); + +const ListFilterFragmentSchema = z.object({ + dimension: z.string(), + values: z.array(PrimitiveValueSchema), + type: z.union([ + z.literal("string"), + z.literal("boolean"), + z.literal("numeric"), + ]), + kind: z.literal("values"), + invert: z.boolean(), +}); +export type ListFilterFragment = z.infer; + +const SearchFilterFragmentSchema = z.object({ + dimension: z.string(), + pattern: z.string(), + filterType: z.union([ + z.literal("default"), + z.literal("startsWith"), + z.literal("endsWith"), + ]), + type: z.literal("string"), + kind: z.literal("search"), + invert: z.boolean().optional(), +}); +export type SearchFilterFragment = z.infer; + +const PeriodFilterFragmentSchema = z.object({ + dimension: z.string(), + type: z.literal("datetime"), + range: z.object({ + inclusive: z.boolean(), + start: z.string(), + end: z.string(), + }), + kind: z.literal("range"), +}); +export type PeriodFilterFragment = z.infer; + +const RangeFilterFragmentSchema = z.object({ + dimension: z.string(), + type: z.literal("numeric"), + range: z.object({ + inclusive: z.boolean(), + start: z.number().optional(), + end: z.number().optional(), + }), + kind: z.literal("range"), +}); +export type RangeFilterFragment = z.infer; + +export const FilterFragmentSchema = z.union([ + ListFilterFragmentSchema, + PeriodFilterFragmentSchema, + RangeFilterFragmentSchema, + SearchFilterFragmentSchema, +]); +export type FilterFragment = z.infer; diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/filter-request-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-request-schema.ts new file mode 100644 index 000000000..bc1406d4c --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/schemas/filter-request-schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { FilterFragmentSchema } from "./filter-fragment-schema"; + +const TableFragmentSchema = z.object({ + kind: z.literal("tableWith"), + table: z.string(), + invert: z.boolean().optional(), + filters: z.array(FilterFragmentSchema), +}); + +const MainTableSchema = z.object({ + kind: z.literal("primarykey"), + table: z.string(), +}); + +export const FilterRequestSchema = z.union([ + FilterFragmentSchema, + TableFragmentSchema, + MainTableSchema, +]); + +export type FilterRequest = z.infer; diff --git a/apps/apollo-vertex/registry/insights-adapter/schemas/query-schema.ts b/apps/apollo-vertex/registry/insights-adapter/schemas/query-schema.ts new file mode 100644 index 000000000..982167971 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/schemas/query-schema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { AggregateFragmentSchema } from "./aggregate-fragment-schema"; +import { FilterRequestSchema } from "./filter-request-schema"; + +export const InsightsQueryRequestSchema = z.object({ + filters: z.array(z.array(FilterRequestSchema)), + groupBy: z.array(z.string()), + aggregates: z.array(AggregateFragmentSchema), + sort: z + .array( + z.object({ + field: z.string(), + direction: z.enum(["asc", "desc"]), + }), + ) + .optional(), + binning: z + .object({ + bins: z.array(z.union([z.string(), z.number()])), + dimension: z.string(), + extraBins: z.enum(["none", "null"]), + }) + .optional(), + stacks: z + .object({ + field: z.string(), + orderByMetric: z.string(), + maxCount: z.number(), + }) + .optional(), +}); + +export type InsightsQueryRequest = z.infer; diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/assert-configuration-supported.ts b/apps/apollo-vertex/registry/insights-adapter/utils/assert-configuration-supported.ts new file mode 100644 index 000000000..3cc81b3bb --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/utils/assert-configuration-supported.ts @@ -0,0 +1,20 @@ +interface ConfigurationWithJoinsAndFrom { + id: string; + joins?: ReadonlyArray; + from?: unknown; +} + +export function assertInsightsConfigurationSupported( + configuration: ConfigurationWithJoinsAndFrom, +): void { + if (configuration.joins && configuration.joins.length > 0) { + throw new Error( + `Insights does not support joins (chart configuration "${configuration.id}").`, + ); + } + if (configuration.from) { + throw new Error( + `Insights does not support "from" (chart configuration "${configuration.id}").`, + ); + } +} diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/binning.ts b/apps/apollo-vertex/registry/insights-adapter/utils/binning.ts new file mode 100644 index 000000000..bd60fad1d --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/utils/binning.ts @@ -0,0 +1,97 @@ +import { + type DateTime, + type DateTimeUnit, + type Duration, + Interval, +} from "luxon"; +import { niceDurationNumbers, niceNumbers } from "@/lib/charts-core"; + +export interface NumericBin { + start: number; + end: number; +} + +function findHighestUnit(duration: Duration): DateTimeUnit { + if (duration.years > 0) return "year"; + if (duration.months > 0) return "month"; + if (duration.days > 0) return "day"; + if (duration.hours > 0) return "hour"; + if (duration.minutes > 0) return "minute"; + if (duration.seconds > 0) return "second"; + return "millisecond"; +} + +function createDatetimeBins({ + start, + end, + highestUnit, + bestBinSize, +}: { + start: DateTime; + end: DateTime; + highestUnit: DateTimeUnit; + bestBinSize: Duration; +}): Interval[] { + const bins: Interval[] = []; + + let currentStart = start; + let currentEnd = currentStart.startOf(highestUnit).plus(bestBinSize); + + while (currentEnd < end) { + bins.push(Interval.fromDateTimes(currentStart, currentEnd)); + currentStart = currentEnd; + currentEnd = currentStart.plus(bestBinSize); + } + bins.push(Interval.fromDateTimes(currentStart, end)); + return bins; +} + +export function calculateDatetimeBins({ + min, + max, +}: { + min: DateTime; + max: DateTime; +}): Interval[] { + if (min.equals(max)) { + return [Interval.fromDateTimes(min, max)]; + } + + const bestBinSize = niceDurationNumbers({ min, max }); + const highestUnit = findHighestUnit(bestBinSize); + + return createDatetimeBins({ + start: min, + end: max, + highestUnit, + bestBinSize, + }); +} + +export function calculateNumericBins({ + min, + max, +}: { + min: number; + max: number; +}): NumericBin[] { + if (min === max) { + return [{ start: min, end: max }]; + } + + const { min: niceMin, binSize } = niceNumbers({ min, max }); + const binCount = Math.ceil((max - niceMin) / binSize); + + return Array.from({ length: binCount }, (_, idx) => ({ + start: niceMin + binSize * idx, + end: niceMin + binSize * (idx + 1), + })); +} + +export function findBinCutoffPoints( + bins: Array, +): number[] { + return bins + .filter((_, idx) => idx < bins.length - 1) + .map(({ end }) => (typeof end === "number" ? end : end.toMillis())); +} diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/filter-request.ts b/apps/apollo-vertex/registry/insights-adapter/utils/filter-request.ts new file mode 100644 index 000000000..e699dadec --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/utils/filter-request.ts @@ -0,0 +1,90 @@ +import type { FilterValues } from "@/lib/charts-core"; +import type { + FilterFragment, + ListFilterFragment, + PeriodFilterFragment, + RangeFilterFragment, + SearchFilterFragment, +} from "../schemas/filter-fragment-schema"; +import type { FilterRequest } from "../schemas/filter-request-schema"; + +function mapFilterValueToFragment(filterValue: FilterValues): FilterFragment { + switch (filterValue.type) { + case "list": + return { + values: filterValue.values, + kind: "values", + dimension: filterValue.field, + type: + filterValue.valueType === "number" + ? "numeric" + : filterValue.valueType, + invert: filterValue.invert ?? false, + } satisfies ListFilterFragment; + case "search": + return { + pattern: filterValue.pattern, + filterType: filterValue.searchFilterType, + kind: "search", + dimension: filterValue.field, + type: "string", + invert: false, + } satisfies SearchFilterFragment; + case "period": + return { + kind: "range", + dimension: filterValue.field, + type: "datetime", + range: { + inclusive: filterValue.range.inclusive ?? false, + start: filterValue.range.min.toISO() ?? "", + end: filterValue.range.max.toISO() ?? "", + }, + } satisfies PeriodFilterFragment; + case "range": + return { + kind: "range", + dimension: filterValue.field, + type: "numeric", + range: { + inclusive: filterValue.range.inclusive ?? false, + ...(filterValue.range.min != null && { + start: filterValue.range.min, + }), + ...(filterValue.range.max != null && { + end: filterValue.range.max, + }), + }, + } satisfies RangeFilterFragment; + } +} + +export function mapFilterValuesToFragments( + filterValues: FilterValues[], +): FilterFragment[] { + return filterValues.map((f) => mapFilterValueToFragment(f)); +} + +export function mapFilterValuesToFilterRequest( + filterValues: FilterValues[], + filterTableId?: string, +): FilterRequest[][] { + const filters = mapFilterValuesToFragments(filterValues); + if (filters.length === 0) { + return []; + } + + if (!filterTableId) { + return [filters]; + } + + return [ + [ + { + kind: "tableWith", + table: filterTableId, + filters, + }, + ], + ]; +} diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/metric-aggregate.ts b/apps/apollo-vertex/registry/insights-adapter/utils/metric-aggregate.ts new file mode 100644 index 000000000..70fbecba7 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/utils/metric-aggregate.ts @@ -0,0 +1,21 @@ +import type { DataModelMetric } from "@/lib/charts-core"; +import type { AggregateFragment } from "../schemas/aggregate-fragment-schema"; +import { mapFilterValuesToFragments } from "./filter-request"; + +export function mapMetricToInsightsAggregate( + metric: DataModelMetric, +): AggregateFragment { + const expr = metric.expression; + const hasFilters = expr.filters != null && expr.filters.length > 0; + + return { + id: metric.id, + expression: { + type: "aggregate", + aggregation: expr.aggregation, + argument: expr.argument.id, + ...(hasFilters && + expr.filters && { filters: mapFilterValuesToFragments(expr.filters) }), + }, + }; +} diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/query-options.ts b/apps/apollo-vertex/registry/insights-adapter/utils/query-options.ts new file mode 100644 index 000000000..8ef92d863 --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/utils/query-options.ts @@ -0,0 +1,36 @@ +import type { FilterValues } from "@/lib/charts-core"; +import type { AggregateFragment } from "../schemas/aggregate-fragment-schema"; +import type { InsightsQueryRequest } from "../schemas/query-schema"; +import { mapFilterValuesToFilterRequest } from "./filter-request"; + +interface InsightsChartQueryOptions { + groupBy: string[]; + aggregates?: AggregateFragment[]; + sortBy: { field: string; direction: "asc" | "desc" } | null; + filters?: FilterValues[]; + filterTableId?: string; + binning?: { dimension: string; bins: number[] }; +} + +export function createInsightsChartQueryOptions({ + groupBy, + aggregates = [], + sortBy, + filters = [], + filterTableId, + binning, +}: InsightsChartQueryOptions): InsightsQueryRequest { + return { + filters: mapFilterValuesToFilterRequest(filters, filterTableId), + groupBy, + aggregates, + ...(binning && { + binning: { + bins: binning.bins, + dimension: binning.dimension, + extraBins: "none" as const, + }, + }), + sort: sortBy ? [{ field: sortBy.field, direction: sortBy.direction }] : [], + }; +} diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/query.ts b/apps/apollo-vertex/registry/insights-adapter/utils/query.ts new file mode 100644 index 000000000..34630758e --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/utils/query.ts @@ -0,0 +1,35 @@ +import type { InitClientReturn } from "@ts-rest/core"; +import type { DataQueryResponse } from "@/lib/charts-core"; +import type { insightsContract } from "../contract"; +import type { InsightsQueryRequest } from "../schemas/query-schema"; +import { throwInsightsError } from "./throw-error"; + +export type InsightsClient = InitClientReturn< + typeof insightsContract, + { baseUrl: string } +>; + +type QueryResponse = + | { status: 200; body: DataQueryResponse } + | { status: 400; body: { error: string } }; + +export async function insightsQuery( + client: InsightsClient, + sourceType: string, + body: InsightsQueryRequest, + errorMessage: string, +): Promise { + // oxlint's tsgolint can't resolve ts-rest's InitClientReturn generic, so it + // sees client.query as `error`-typed; the cast restores the real shape. + // oxlint-disable-next-line typescript-eslint/no-unsafe-call, typescript-eslint/no-unsafe-type-assertion + const result = (await client.query({ + params: { sourceType }, + body, + })) as QueryResponse; + + if (result.status !== 200) { + throwInsightsError(result, errorMessage); + } + + return result.body; +} diff --git a/apps/apollo-vertex/registry/insights-adapter/utils/throw-error.ts b/apps/apollo-vertex/registry/insights-adapter/utils/throw-error.ts new file mode 100644 index 000000000..21ca1e8bb --- /dev/null +++ b/apps/apollo-vertex/registry/insights-adapter/utils/throw-error.ts @@ -0,0 +1,6 @@ +export function throwInsightsError( + result: { status: 400; body: { error: string } }, + fallback: string, +): never { + throw new Error(result.body.error || fallback); +} diff --git a/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx b/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx index 5274728ca..e54531de8 100644 --- a/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx +++ b/apps/apollo-vertex/registry/kpi-chart/kpi-chart-with-adapter.tsx @@ -62,7 +62,7 @@ function KpiChartResolver({ const numericValue = rawValue == null ? 0 : assertNumber(rawValue, "KPI numeric value"); const valueText = metric - ? formatMetricValue(language, numericValue, metric.aggregation) + ? formatMetricValue(language, numericValue, metric.expression) : ""; return ( diff --git a/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx b/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx index 73397e51f..2f81240b2 100644 --- a/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx +++ b/apps/apollo-vertex/registry/line-chart/line-chart-with-adapter.tsx @@ -95,7 +95,7 @@ function LineChartResolver({ data={data} seriesLabel={metric.display} formatValue={(value) => - formatMetricValue(language, value, metric.aggregation) + formatMetricValue(language, value, metric.expression) } /> diff --git a/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx b/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx index f6d58c902..856c79265 100644 --- a/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx +++ b/apps/apollo-vertex/registry/multi-line-chart/multi-line-chart-with-adapter.tsx @@ -114,8 +114,8 @@ function MultiLineChartResolver({ color, axis: idx === 0 ? "left" : "right", formatValue: (value: number) => - formatMetricValue(language, value, metric.aggregation), - totalText: formatMetricValue(language, total, metric.aggregation), + formatMetricValue(language, value, metric.expression), + totalText: formatMetricValue(language, total, metric.expression), totalLabel: t("total_metric", { metric: metric.display }), }; }); diff --git a/apps/apollo-vertex/tsconfig.json b/apps/apollo-vertex/tsconfig.json index 2923adb6e..10c7ae5b6 100644 --- a/apps/apollo-vertex/tsconfig.json +++ b/apps/apollo-vertex/tsconfig.json @@ -36,6 +36,9 @@ "@/lib/data-fabric-adapter": [ "./registry/data-fabric-adapter/data-fabric-adapter" ], + "@/lib/insights-adapter": [ + "./registry/insights-adapter/insights-adapter" + ], "@/components/ui/line-chart": ["./registry/line-chart/line-chart"], "@/components/ui/multi-line-chart": [ "./registry/multi-line-chart/multi-line-chart"