Skip to content

Commit 613d5cb

Browse files
committed
feat: export notify for write-back feat
1 parent 9950a77 commit 613d5cb

6 files changed

Lines changed: 241 additions & 9 deletions

File tree

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

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import {
99
BarChart,
1010
Button,
1111
Card,
12+
CardAction,
1213
CardContent,
1314
CardDescription,
1415
CardHeader,
1516
CardTitle,
1617
DonutChart,
1718
LineChart,
19+
notify,
1820
Select,
1921
SelectContent,
2022
SelectItem,
@@ -30,6 +32,7 @@ import {
3032
useMetricView,
3133
} from "@databricks/appkit-ui/react";
3234
import { createFileRoute } from "@tanstack/react-router";
35+
import { FilterIcon } from "lucide-react";
3336
import { useCallback, useMemo, useState } from "react";
3437
import { Header } from "@/components/layout/header";
3538

@@ -87,6 +90,47 @@ function buildFilter(
8790
return toMetricFilter(shorthand);
8891
}
8992

93+
/**
94+
* The dimensions actually shaping a card's data, given the shared selection and
95+
* the card's own excluded dimension. Mirrors `buildFilter`'s facet-exclusion so
96+
* the badge tells the truth per-card: the ARR-by-region card excludes `region`,
97+
* so it never claims a region filter it deliberately ignores.
98+
*/
99+
function appliedDimensions(
100+
selection: Selection,
101+
exclude?: FilterDimension,
102+
): FilterDimension[] {
103+
return FILTER_DIMENSIONS.filter(
104+
(dimension) => dimension !== exclude && selection[dimension] !== undefined,
105+
);
106+
}
107+
108+
/**
109+
* Header badge that makes a card explicit about which filters shaped its data.
110+
* Renders nothing when the card is unfiltered, so an unsliced card stays clean.
111+
* Placed in `CardAction` (top-right of the header) via the caller.
112+
*/
113+
function FilterBadge({
114+
selection,
115+
exclude,
116+
}: {
117+
selection: Selection;
118+
exclude?: FilterDimension;
119+
}) {
120+
const applied = appliedDimensions(selection, exclude);
121+
if (applied.length === 0) return null;
122+
return (
123+
<Badge variant="secondary" className="gap-1">
124+
<FilterIcon className="size-3" aria-hidden />
125+
{applied
126+
.map(
127+
(dimension) => `${formatLabel(dimension)}: ${selection[dimension]}`,
128+
)
129+
.join(" · ")}
130+
</Badge>
131+
);
132+
}
133+
90134
/**
91135
* Loading / error / empty state shared by every visual card. Returns `null`
92136
* once data has rows so the caller renders the visual.
@@ -313,6 +357,11 @@ function MetricViewsRoute() {
313357
<CardDescription>
314358
revenue · arr · grouped by region
315359
</CardDescription>
360+
<CardAction>
361+
{/* Excludes `region` — same facet-exclusion as this card's
362+
filter, so it never claims the region slice it ignores. */}
363+
<FilterBadge selection={selection} exclude="region" />
364+
</CardAction>
316365
</CardHeader>
317366
<CardContent>
318367
<VisualStatus
@@ -329,7 +378,6 @@ function MetricViewsRoute() {
329378
xKey="region"
330379
yKey="arr"
331380
height={280}
332-
title="Annual recurring revenue by region"
333381
onDataClick={(d) => setDimension("region", d.name)}
334382
selected={selection.region}
335383
/>
@@ -344,6 +392,9 @@ function MetricViewsRoute() {
344392
<CardDescription>
345393
revenue · arr · grouped by segment
346394
</CardDescription>
395+
<CardAction>
396+
<FilterBadge selection={selection} exclude="segment" />
397+
</CardAction>
347398
</CardHeader>
348399
<CardContent>
349400
<VisualStatus
@@ -362,7 +413,6 @@ function MetricViewsRoute() {
362413
height={280}
363414
innerRadius={55}
364415
showLegend
365-
title="Annual recurring revenue by segment"
366416
onDataClick={(d) => setDimension("segment", d.name)}
367417
selected={selection.segment}
368418
/>
@@ -378,6 +428,11 @@ function MetricViewsRoute() {
378428
<CardDescription>
379429
revenue · measures {TREND_MEASURES.join(", ")} · grouped by month
380430
</CardDescription>
431+
<CardAction>
432+
{/* No `exclude` — the trend groups by time, so it applies the
433+
full selection (both region and segment narrow it). */}
434+
<FilterBadge selection={selection} />
435+
</CardAction>
381436
</CardHeader>
382437
<CardContent>
383438
<VisualStatus
@@ -395,7 +450,45 @@ function MetricViewsRoute() {
395450
yKey={[...TREND_MEASURES]}
396451
height={320}
397452
showLegend
398-
title="ARR vs MRR over time"
453+
// Render point symbols + trigger clicks along the whole line
454+
// (SDK's triggerLineEvent, on because onDataClick is set) so
455+
// the hairline isn't the only hit target.
456+
showSymbol
457+
// Clicking a point fires a transient "write-back" toast (the
458+
// "Apps = action layer" gesture) through the same Toaster the
459+
// warehouse-status indicator uses.
460+
onDataClick={(d) => {
461+
// Resolve the measure by series INDEX — yKey order ===
462+
// TREND_MEASURES order, and sorting only reorders points
463+
// WITHIN a series, not the series array. (Parsing the "Arr"
464+
// label wouldn't round-trip to keys like "new_arr", and
465+
// indexing trend.data by dataIndex is wrong because the
466+
// time series is sorted before rendering.)
467+
const measureKey = TREND_MEASURES[d.seriesIndex];
468+
if (!measureKey) return;
469+
const meta = trend.metadata?.[measureKey];
470+
const label = meta?.display_name ?? measureKey;
471+
// A time-series point is a [epochMs, value] tuple on
472+
// ECharts' params.value (d.value is null for tuples); read
473+
// it off the raw params so both the date and amount survive.
474+
const tuple = (d.raw as { value?: unknown } | undefined)
475+
?.value;
476+
const [ts, amount] = Array.isArray(tuple)
477+
? tuple
478+
: [undefined, undefined];
479+
const month =
480+
typeof ts === "number"
481+
? new Date(ts).toLocaleDateString(undefined, {
482+
year: "numeric",
483+
month: "short",
484+
})
485+
: String(d.name || "");
486+
const formatted = formatValue(amount, meta?.format);
487+
notify.message(
488+
`Write back: ${label} · ${month} · ${formatted}`,
489+
{ description: "Point selected for write-back (demo)." },
490+
);
491+
}}
399492
/>
400493
)}
401494
</CardContent>
@@ -409,6 +502,11 @@ function MetricViewsRoute() {
409502
Click a row to filter every visual by that region — click again
410503
(or a chip above) to clear.
411504
</CardDescription>
505+
<CardAction>
506+
{/* Grouped by region, so it excludes `region` (same as the region
507+
bar) — a segment filter still narrows it. */}
508+
<FilterBadge selection={selection} exclude="region" />
509+
</CardAction>
412510
</CardHeader>
413511
<CardContent>
414512
<VisualStatus

packages/appkit-ui/src/react/charts/__tests__/options.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface EChartsOption {
2828
showSymbol?: boolean;
2929
symbol?: string;
3030
symbolSize?: number;
31+
triggerLineEvent?: boolean;
3132
areaStyle?: { opacity: number };
3233
stack?: string;
3334
itemStyle?: { borderRadius?: number[] };
@@ -200,6 +201,65 @@ describe("buildCartesianOption", () => {
200201
expect(opt.series[0].smooth).toBe(false);
201202
expect(opt.series[0].showSymbol).toBe(false);
202203
});
204+
205+
test("applies symbolSize to line series (not just scatter)", () => {
206+
const ctx = createBaseContext();
207+
const opt = asOption(
208+
buildCartesianOption({
209+
...ctx,
210+
chartType: "line",
211+
isTimeSeries: false,
212+
stacked: false,
213+
smooth: true,
214+
showSymbol: true,
215+
symbolSize: 14,
216+
}),
217+
);
218+
219+
expect(opt.series[0].symbolSize).toBe(14);
220+
});
221+
222+
test("sets triggerLineEvent only when interactive", () => {
223+
const ctx = createBaseContext();
224+
const base = {
225+
...ctx,
226+
chartType: "line" as const,
227+
isTimeSeries: false,
228+
stacked: false,
229+
smooth: true,
230+
showSymbol: true,
231+
symbolSize: 8,
232+
};
233+
234+
// Non-interactive line: no triggerLineEvent.
235+
expect(
236+
asOption(buildCartesianOption(base)).series[0].triggerLineEvent,
237+
).toBeUndefined();
238+
239+
// Interactive line: whole stroke is clickable.
240+
expect(
241+
asOption(buildCartesianOption({ ...base, interactive: true })).series[0]
242+
.triggerLineEvent,
243+
).toBe(true);
244+
});
245+
246+
test("does not set triggerLineEvent on a bar series even when interactive", () => {
247+
const ctx = createBaseContext();
248+
const opt = asOption(
249+
buildCartesianOption({
250+
...ctx,
251+
chartType: "bar",
252+
isTimeSeries: false,
253+
stacked: false,
254+
smooth: false,
255+
showSymbol: false,
256+
symbolSize: 8,
257+
interactive: true,
258+
}),
259+
);
260+
261+
expect(opt.series[0].triggerLineEvent).toBeUndefined();
262+
});
203263
});
204264

205265
describe("area chart", () => {

packages/appkit-ui/src/react/charts/base.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,15 @@ export function BaseChart({
232232

233233
const ui = useChartUITokens();
234234

235+
// Only the *presence* of a click handler shapes the option (it flips
236+
// `triggerLineEvent`/`symbolSize` on line/area). Depend on this boolean, not
237+
// the handler reference — consumers pass an inline `onDataClick`, whose
238+
// identity changes every render, so depending on the reference would rebuild
239+
// the whole option object on every parent re-render (e.g. each SSE tick). The
240+
// `onEvents` memo below still depends on `onDataClick` itself — it needs the
241+
// real function.
242+
const interactive = !!onDataClick;
243+
235244
// Store ECharts instance directly to avoid stale ref issues on unmount
236245
const echartsInstanceRef = useRef<ECharts | null>(null);
237246

@@ -348,6 +357,9 @@ export function BaseChart({
348357
smooth,
349358
showSymbol,
350359
symbolSize,
360+
// A click handler turns on `triggerLineEvent` for line/area so the
361+
// whole stroke is clickable, not just symbols.
362+
interactive,
351363
});
352364
}
353365

@@ -375,6 +387,7 @@ export function BaseChart({
375387
max,
376388
customOptions,
377389
selected,
390+
interactive,
378391
]);
379392

380393
// Build the ECharts event map only when a click handler is provided. Memoized

packages/appkit-ui/src/react/charts/options.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export interface CartesianContext extends OptionBuilderContext {
2929
smooth: boolean;
3030
showSymbol: boolean;
3131
symbolSize: number;
32+
/**
33+
* Whether a click handler is attached. When true, line/area series set
34+
* `triggerLineEvent` so a click anywhere on the stroke fires (not just on a
35+
* symbol) — otherwise clicking a thin line is nearly impossible to land.
36+
*/
37+
interactive?: boolean;
3238
}
3339

3440
// ============================================================================
@@ -331,11 +337,19 @@ export function buildCartesianOption(
331337
ctx: CartesianContext,
332338
): Record<string, unknown> {
333339
const ui = ctx.ui ?? FALLBACK_UI_TOKENS;
334-
const { chartType, isTimeSeries, stacked, smooth, showSymbol, symbolSize } =
335-
ctx;
340+
const {
341+
chartType,
342+
isTimeSeries,
343+
stacked,
344+
smooth,
345+
showSymbol,
346+
symbolSize,
347+
interactive,
348+
} = ctx;
336349
const hasMultipleSeries = ctx.yFields.length > 1;
337350
const seriesType = chartType === "area" ? "line" : chartType;
338351
const isScatter = chartType === "scatter";
352+
const isLineLike = chartType === "line" || chartType === "area";
339353

340354
return {
341355
...buildBaseOption(ctx),
@@ -378,11 +392,15 @@ export function buildCartesianOption(
378392
: isTimeSeries
379393
? createTimeSeriesData(ctx.xData, ctx.yDataMap[key])
380394
: ctx.yDataMap[key],
381-
smooth: chartType === "line" || chartType === "area" ? smooth : undefined,
382-
showSymbol:
383-
chartType === "line" || chartType === "area" ? showSymbol : undefined,
395+
smooth: isLineLike ? smooth : undefined,
396+
showSymbol: isLineLike ? showSymbol : undefined,
384397
symbol: isScatter ? "circle" : undefined,
385-
symbolSize: isScatter ? symbolSize : undefined,
398+
// Symbol size now applies to line/area too (previously scatter-only), so
399+
// an interactive line can present a clickable point, not just a hairline.
400+
symbolSize: isScatter || isLineLike ? symbolSize : undefined,
401+
// Fire click events along the whole line stroke, not only on symbols,
402+
// when the chart is interactive. No effect on non-line series.
403+
triggerLineEvent: isLineLike && interactive ? true : undefined,
386404
areaStyle: chartType === "area" ? { opacity: 0.3 } : undefined,
387405
stack: stacked && chartType === "area" ? "total" : undefined,
388406
itemStyle:

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export * from "./sheet";
4141
export * from "./sidebar";
4242
export * from "./skeleton";
4343
export * from "./slider";
44+
export * from "./notify";
4445
export * from "./sonner";
4546
export * from "./spinner";
4647
export * from "./switch";
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ReactNode } from "react";
2+
import { toast } from "sonner";
3+
4+
/** Options for a {@link notify} toast — a curated subset of sonner's surface. */
5+
export interface NotifyOptions {
6+
/** Secondary line under the title. */
7+
description?: ReactNode;
8+
/** Auto-dismiss delay in ms. Omit for sonner's default; `Infinity` to make it sticky. */
9+
duration?: number;
10+
}
11+
12+
/**
13+
* Fire a transient toast through the app's mounted `<Toaster />` — the same
14+
* sonner surface `ResourceStatusIndicator` renders warehouse-readiness into.
15+
*
16+
* This is a curated wrapper so app code never imports sonner directly: it
17+
* exposes only a title + `{ description, duration }`, not sonner's full option
18+
* bag. Requires a `<Toaster />` (or `<ResourceStatusIndicator />`) mounted in
19+
* the tree; without one the call is a no-op.
20+
*
21+
* @example
22+
* ```tsx
23+
* notify.message("Write back: Arr · Apr 2026 · $8,100,000");
24+
* notify.success("Saved", { description: "Row written back to the source." });
25+
* ```
26+
*/
27+
export const notify = {
28+
/** Neutral message toast. */
29+
message: (title: ReactNode, options?: NotifyOptions) =>
30+
toast(title, options),
31+
/** Informational toast. */
32+
info: (title: ReactNode, options?: NotifyOptions) => toast.info(title, options),
33+
/** Success toast. */
34+
success: (title: ReactNode, options?: NotifyOptions) =>
35+
toast.success(title, options),
36+
/** Warning toast. */
37+
warning: (title: ReactNode, options?: NotifyOptions) =>
38+
toast.warning(title, options),
39+
/** Error toast. */
40+
error: (title: ReactNode, options?: NotifyOptions) =>
41+
toast.error(title, options),
42+
};

0 commit comments

Comments
 (0)