@agent-native/toolkit 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +30 -0
  2. package/agent-native.eject.json +33 -0
  3. package/dist/dashboard/DataTable.d.ts +20 -0
  4. package/dist/dashboard/DataTable.d.ts.map +1 -0
  5. package/dist/dashboard/DataTable.js +87 -0
  6. package/dist/dashboard/DataTable.js.map +1 -0
  7. package/dist/dashboard/DateRangePicker.d.ts +15 -0
  8. package/dist/dashboard/DateRangePicker.d.ts.map +1 -0
  9. package/dist/dashboard/DateRangePicker.js +15 -0
  10. package/dist/dashboard/DateRangePicker.js.map +1 -0
  11. package/dist/dashboard/GenericChartPanel.d.ts +57 -0
  12. package/dist/dashboard/GenericChartPanel.d.ts.map +1 -0
  13. package/dist/dashboard/GenericChartPanel.js +119 -0
  14. package/dist/dashboard/GenericChartPanel.js.map +1 -0
  15. package/dist/dashboard/GenericChartPanel.spec.d.ts +2 -0
  16. package/dist/dashboard/GenericChartPanel.spec.d.ts.map +1 -0
  17. package/dist/dashboard/GenericChartPanel.spec.js +41 -0
  18. package/dist/dashboard/GenericChartPanel.spec.js.map +1 -0
  19. package/dist/dashboard/MetricCard.d.ts +14 -0
  20. package/dist/dashboard/MetricCard.d.ts.map +1 -0
  21. package/dist/dashboard/MetricCard.js +10 -0
  22. package/dist/dashboard/MetricCard.js.map +1 -0
  23. package/dist/dashboard/StatsCard.d.ts +16 -0
  24. package/dist/dashboard/StatsCard.d.ts.map +1 -0
  25. package/dist/dashboard/StatsCard.js +8 -0
  26. package/dist/dashboard/StatsCard.js.map +1 -0
  27. package/dist/dashboard/dashboard-layout.d.ts +49 -0
  28. package/dist/dashboard/dashboard-layout.d.ts.map +1 -0
  29. package/dist/dashboard/dashboard-layout.js +231 -0
  30. package/dist/dashboard/dashboard-layout.js.map +1 -0
  31. package/dist/dashboard/dashboard-layout.spec.d.ts +2 -0
  32. package/dist/dashboard/dashboard-layout.spec.d.ts.map +1 -0
  33. package/dist/dashboard/dashboard-layout.spec.js +52 -0
  34. package/dist/dashboard/dashboard-layout.spec.js.map +1 -0
  35. package/dist/dashboard/index.d.ts +7 -0
  36. package/dist/dashboard/index.d.ts.map +1 -0
  37. package/dist/dashboard/index.js +7 -0
  38. package/dist/dashboard/index.js.map +1 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/package.json +15 -1
  44. package/src/dashboard/DataTable.tsx +273 -0
  45. package/src/dashboard/DateRangePicker.tsx +57 -0
  46. package/src/dashboard/GenericChartPanel.spec.tsx +53 -0
  47. package/src/dashboard/GenericChartPanel.tsx +461 -0
  48. package/src/dashboard/MetricCard.tsx +54 -0
  49. package/src/dashboard/StatsCard.tsx +49 -0
  50. package/src/dashboard/dashboard-layout.spec.ts +79 -0
  51. package/src/dashboard/dashboard-layout.ts +329 -0
  52. package/src/dashboard/index.ts +6 -0
  53. package/src/index.ts +1 -0
@@ -0,0 +1,461 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ Area,
4
+ AreaChart,
5
+ Bar,
6
+ BarChart,
7
+ CartesianGrid,
8
+ Cell,
9
+ Legend,
10
+ Line,
11
+ LineChart,
12
+ Pie,
13
+ PieChart,
14
+ ResponsiveContainer,
15
+ Tooltip,
16
+ XAxis,
17
+ YAxis,
18
+ } from "recharts";
19
+
20
+ import { cn } from "../utils.js";
21
+ import { DataTable } from "./DataTable.js";
22
+
23
+ const DEFAULT_COLORS = [
24
+ "hsl(var(--chart-1, 0 0% 20%))",
25
+ "hsl(var(--chart-2, 155 36% 42%))",
26
+ "hsl(var(--chart-3, 32 55% 50%))",
27
+ "hsl(var(--chart-4, 270 25% 55%))",
28
+ "hsl(var(--chart-5, 350 38% 55%))",
29
+ "hsl(var(--chart-6, 190 25% 48%))",
30
+ ];
31
+
32
+ export type GenericChartDatum = Record<string, unknown>;
33
+ export type GenericChartType =
34
+ | "metric"
35
+ | "line"
36
+ | "area"
37
+ | "bar"
38
+ | "stacked-bar"
39
+ | "stacked-area"
40
+ | "pie"
41
+ | "donut"
42
+ | "table";
43
+
44
+ export interface GenericChartConfig {
45
+ chartType: GenericChartType;
46
+ xKey?: string;
47
+ yKey?: string;
48
+ yKeys?: string[];
49
+ colors?: string[];
50
+ stacked?: boolean;
51
+ legend?: boolean;
52
+ description?: string;
53
+ columns?: string[];
54
+ limit?: number;
55
+ formatValue?: (value: number) => string;
56
+ formatXAxis?: (value: string) => string;
57
+ formatSeriesName?: (value: string) => string;
58
+ }
59
+
60
+ export interface GenericChartRenderContext<TData, TConfig> {
61
+ data: TData;
62
+ config: TConfig;
63
+ }
64
+
65
+ export interface GenericChartPanelProps<TData, TConfig> {
66
+ data: TData | null | undefined;
67
+ config: TConfig;
68
+ loading?: boolean;
69
+ error?: ReactNode;
70
+ isEmpty?: (data: TData) => boolean;
71
+ /** App-specific rendering takes priority over the portable Recharts renderer. */
72
+ render?: (context: GenericChartRenderContext<TData, TConfig>) => ReactNode;
73
+ /** Opt into Toolkit's source-agnostic Recharts renderer for row data. */
74
+ chart?: GenericChartConfig;
75
+ renderLoading?: (config: TConfig) => ReactNode;
76
+ renderError?: (error: ReactNode, config: TConfig) => ReactNode;
77
+ renderEmpty?: (config: TConfig) => ReactNode;
78
+ emptyMessage?: ReactNode;
79
+ className?: string;
80
+ }
81
+
82
+ /**
83
+ * A render-only chart state boundary. Data acquisition, query serialization,
84
+ * demos, pivots, and app-specific chart renderers stay with the consuming app.
85
+ */
86
+ export function GenericChartPanel<TData, TConfig>({
87
+ data,
88
+ config,
89
+ loading = false,
90
+ error,
91
+ isEmpty,
92
+ render,
93
+ chart,
94
+ renderLoading,
95
+ renderError,
96
+ renderEmpty,
97
+ emptyMessage = "No data available.",
98
+ className,
99
+ }: GenericChartPanelProps<TData, TConfig>) {
100
+ if (loading) {
101
+ return (
102
+ <>
103
+ {renderLoading?.(config) ?? (
104
+ <ChartPanelPlaceholder className={className} />
105
+ )}
106
+ </>
107
+ );
108
+ }
109
+
110
+ if (error) {
111
+ return (
112
+ <>
113
+ {renderError?.(error, config) ?? (
114
+ <ChartPanelMessage className={className} tone="error">
115
+ {error}
116
+ </ChartPanelMessage>
117
+ )}
118
+ </>
119
+ );
120
+ }
121
+
122
+ if (data == null || isEmpty?.(data)) {
123
+ return (
124
+ <>
125
+ {renderEmpty?.(config) ?? (
126
+ <ChartPanelMessage className={className}>
127
+ {emptyMessage}
128
+ </ChartPanelMessage>
129
+ )}
130
+ </>
131
+ );
132
+ }
133
+
134
+ if (render) return <>{render({ data, config })}</>;
135
+ if (chart && Array.isArray(data)) {
136
+ return (
137
+ <GenericChartRenderer rows={data as GenericChartDatum[]} config={chart} />
138
+ );
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ /** Derives chart axes from provider-neutral row data when keys are not configured. */
145
+ export function resolveGenericChartKeys(
146
+ rows: GenericChartDatum[],
147
+ config: Pick<GenericChartConfig, "xKey" | "yKey" | "yKeys">,
148
+ ): { xKey: string; yKeys: string[] } {
149
+ const columns = Object.keys(rows[0] ?? {});
150
+ const xKey =
151
+ config.xKey && columns.includes(config.xKey)
152
+ ? config.xKey
153
+ : (columns[0] ?? "");
154
+ const configured = (
155
+ config.yKeys?.length ? config.yKeys : config.yKey ? [config.yKey] : []
156
+ ).filter((key) => columns.includes(key));
157
+ const yKeys = configured.length
158
+ ? configured
159
+ : columns.filter((key) => key !== xKey && isNumericLike(rows[0]?.[key]));
160
+ return {
161
+ xKey,
162
+ yKeys: yKeys.length
163
+ ? yKeys
164
+ : columns.filter((key) => key !== xKey).slice(0, 1),
165
+ };
166
+ }
167
+
168
+ export function formatGenericChartValue(value: unknown): string {
169
+ if (typeof value === "number") return value.toLocaleString();
170
+ if (
171
+ typeof value === "string" &&
172
+ value.trim() !== "" &&
173
+ Number.isFinite(Number(value))
174
+ ) {
175
+ return Number(value).toLocaleString();
176
+ }
177
+ return String(value ?? "-");
178
+ }
179
+
180
+ function isNumericLike(value: unknown): boolean {
181
+ return (
182
+ typeof value === "number" ||
183
+ (typeof value === "string" &&
184
+ value.trim() !== "" &&
185
+ Number.isFinite(Number(value)))
186
+ );
187
+ }
188
+
189
+ export function GenericChartRenderer({
190
+ rows,
191
+ config,
192
+ }: {
193
+ rows: GenericChartDatum[];
194
+ config: GenericChartConfig;
195
+ }) {
196
+ const { xKey, yKeys } = resolveGenericChartKeys(rows, config);
197
+ const colors = config.colors?.length ? config.colors : DEFAULT_COLORS;
198
+ const valueFormatter = config.formatValue ?? formatGenericChartValue;
199
+ const xFormatter = config.formatXAxis ?? ((value: string) => value);
200
+ const seriesFormatter = config.formatSeriesName ?? ((value: string) => value);
201
+
202
+ if (config.chartType === "metric") {
203
+ const valueKey =
204
+ config.yKey && xKey !== config.yKey ? config.yKey : (yKeys[0] ?? xKey);
205
+ const first = rows[0]?.[valueKey];
206
+ const raw =
207
+ rows.length > 1 && rows.every((row) => isNumericLike(row[valueKey]))
208
+ ? rows.reduce((sum, row) => sum + Number(row[valueKey]), 0)
209
+ : first;
210
+ const value = isNumericLike(raw)
211
+ ? valueFormatter(Number(raw))
212
+ : formatGenericChartValue(raw);
213
+ return (
214
+ <div className="flex min-h-12 flex-1 flex-col items-center justify-center py-2 text-center">
215
+ <div className="text-3xl font-bold">{value}</div>
216
+ {config.description && (
217
+ <p className="mt-1 text-xs text-muted-foreground">
218
+ {config.description}
219
+ </p>
220
+ )}
221
+ </div>
222
+ );
223
+ }
224
+
225
+ if (config.chartType === "table") {
226
+ return (
227
+ <DataTable data={rows} columns={config.columns} maxRows={config.limit} />
228
+ );
229
+ }
230
+
231
+ if (!xKey || yKeys.length === 0) {
232
+ return (
233
+ <ChartPanelMessage>
234
+ Chart data needs an x-axis and at least one value column.
235
+ </ChartPanelMessage>
236
+ );
237
+ }
238
+
239
+ if (config.chartType === "pie" || config.chartType === "donut") {
240
+ const valueKey = yKeys[0];
241
+ return (
242
+ <ChartCanvas>
243
+ <PieChart>
244
+ <Pie
245
+ data={rows}
246
+ dataKey={valueKey}
247
+ nameKey={xKey}
248
+ cx="50%"
249
+ cy="50%"
250
+ innerRadius={config.chartType === "donut" ? 52 : 0}
251
+ outerRadius={80}
252
+ label={({ name, percent }) =>
253
+ `${seriesFormatter(String(name))} ${((percent ?? 0) * 100).toFixed(0)}%`
254
+ }
255
+ labelLine={false}
256
+ >
257
+ {rows.map((_, index) => (
258
+ <Cell key={index} fill={colors[index % colors.length]} />
259
+ ))}
260
+ </Pie>
261
+ <Tooltip
262
+ formatter={(value: unknown) =>
263
+ formatTooltipValue(value, valueFormatter)
264
+ }
265
+ />
266
+ {config.legend !== false && (
267
+ <Legend formatter={(value) => seriesFormatter(String(value))} />
268
+ )}
269
+ </PieChart>
270
+ </ChartCanvas>
271
+ );
272
+ }
273
+
274
+ const stacked =
275
+ config.stacked ||
276
+ config.chartType === "stacked-bar" ||
277
+ config.chartType === "stacked-area";
278
+ if (config.chartType === "bar" || config.chartType === "stacked-bar") {
279
+ return (
280
+ <ChartCanvas>
281
+ <BarChart data={rows}>
282
+ <ChartAxes
283
+ xKey={xKey}
284
+ yFormatter={valueFormatter}
285
+ xFormatter={xFormatter}
286
+ />
287
+ <Tooltip
288
+ formatter={(value: unknown) =>
289
+ formatTooltipValue(value, valueFormatter)
290
+ }
291
+ labelFormatter={(value) => xFormatter(String(value))}
292
+ />
293
+ {config.legend !== false && (
294
+ <Legend formatter={(value) => seriesFormatter(String(value))} />
295
+ )}
296
+ {yKeys.map((key, index) => (
297
+ <Bar
298
+ key={key}
299
+ dataKey={key}
300
+ name={seriesFormatter(key)}
301
+ fill={colors[index % colors.length]}
302
+ stackId={stacked ? "stack" : undefined}
303
+ radius={stacked && index < yKeys.length - 1 ? 0 : 4}
304
+ />
305
+ ))}
306
+ </BarChart>
307
+ </ChartCanvas>
308
+ );
309
+ }
310
+
311
+ const showFill =
312
+ config.chartType === "area" || config.chartType === "stacked-area";
313
+ return (
314
+ <ChartCanvas>
315
+ {showFill ? (
316
+ <AreaChart data={rows}>
317
+ <ChartAxes
318
+ xKey={xKey}
319
+ yFormatter={valueFormatter}
320
+ xFormatter={xFormatter}
321
+ />
322
+ <Tooltip
323
+ formatter={(value: unknown) =>
324
+ formatTooltipValue(value, valueFormatter)
325
+ }
326
+ labelFormatter={(value) => xFormatter(String(value))}
327
+ />
328
+ {config.legend !== false && (
329
+ <Legend formatter={(value) => seriesFormatter(String(value))} />
330
+ )}
331
+ {yKeys.map((key, index) => (
332
+ <Area
333
+ key={key}
334
+ type="monotone"
335
+ dataKey={key}
336
+ name={seriesFormatter(key)}
337
+ stroke={colors[index % colors.length]}
338
+ fill={colors[index % colors.length]}
339
+ fillOpacity={stacked || yKeys.length === 1 ? 0.25 : 0}
340
+ stackId={stacked ? "stack" : undefined}
341
+ />
342
+ ))}
343
+ </AreaChart>
344
+ ) : (
345
+ <LineChart data={rows}>
346
+ <ChartAxes
347
+ xKey={xKey}
348
+ yFormatter={valueFormatter}
349
+ xFormatter={xFormatter}
350
+ />
351
+ <Tooltip
352
+ formatter={(value: unknown) =>
353
+ formatTooltipValue(value, valueFormatter)
354
+ }
355
+ labelFormatter={(value) => xFormatter(String(value))}
356
+ />
357
+ {config.legend !== false && (
358
+ <Legend formatter={(value) => seriesFormatter(String(value))} />
359
+ )}
360
+ {yKeys.map((key, index) => (
361
+ <Line
362
+ key={key}
363
+ type="monotone"
364
+ dataKey={key}
365
+ name={seriesFormatter(key)}
366
+ stroke={colors[index % colors.length]}
367
+ strokeWidth={2}
368
+ dot={false}
369
+ />
370
+ ))}
371
+ </LineChart>
372
+ )}
373
+ </ChartCanvas>
374
+ );
375
+ }
376
+
377
+ function ChartCanvas({ children }: { children: ReactNode }) {
378
+ return (
379
+ <div className="h-[250px] w-full">
380
+ <ResponsiveContainer width="100%" height="100%">
381
+ {children}
382
+ </ResponsiveContainer>
383
+ </div>
384
+ );
385
+ }
386
+
387
+ function ChartAxes({
388
+ xKey,
389
+ yFormatter,
390
+ xFormatter,
391
+ }: {
392
+ xKey: string;
393
+ yFormatter: (value: number) => string;
394
+ xFormatter: (value: string) => string;
395
+ }) {
396
+ return (
397
+ <>
398
+ <XAxis
399
+ dataKey={xKey}
400
+ stroke="hsl(var(--muted-foreground))"
401
+ fontSize={12}
402
+ tickLine={false}
403
+ axisLine={false}
404
+ tickFormatter={(value) => xFormatter(String(value))}
405
+ />
406
+ <YAxis
407
+ stroke="hsl(var(--muted-foreground))"
408
+ fontSize={12}
409
+ tickLine={false}
410
+ axisLine={false}
411
+ tickFormatter={(value) => yFormatter(Number(value))}
412
+ />
413
+ <CartesianGrid
414
+ strokeDasharray="3 3"
415
+ stroke="hsl(var(--border))"
416
+ vertical={false}
417
+ />
418
+ </>
419
+ );
420
+ }
421
+
422
+ function formatTooltipValue(
423
+ value: unknown,
424
+ formatter: (value: number) => string,
425
+ ): string {
426
+ const numeric = Number(value);
427
+ return Number.isFinite(numeric) ? formatter(numeric) : String(value ?? "-");
428
+ }
429
+
430
+ export function ChartPanelPlaceholder({ className }: { className?: string }) {
431
+ return (
432
+ <div
433
+ className={cn(
434
+ "min-h-[250px] w-full animate-pulse rounded-md bg-muted",
435
+ className,
436
+ )}
437
+ />
438
+ );
439
+ }
440
+
441
+ function ChartPanelMessage({
442
+ children,
443
+ className,
444
+ tone = "muted",
445
+ }: {
446
+ children: ReactNode;
447
+ className?: string;
448
+ tone?: "error" | "muted";
449
+ }) {
450
+ return (
451
+ <div
452
+ className={cn(
453
+ "flex min-h-[250px] items-center justify-center px-4 py-8 text-center text-sm",
454
+ tone === "error" ? "text-destructive" : "text-muted-foreground",
455
+ className,
456
+ )}
457
+ >
458
+ {children}
459
+ </div>
460
+ );
461
+ }
@@ -0,0 +1,54 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card.js";
4
+ import { Skeleton } from "../ui/skeleton.js";
5
+
6
+ export interface MetricCardProps {
7
+ title: string;
8
+ value: string | number | null;
9
+ icon?: ComponentType<{ className?: string }>;
10
+ description?: string;
11
+ loading?: boolean;
12
+ error?: string;
13
+ }
14
+
15
+ /** A compact, data-source-agnostic dashboard metric. */
16
+ export function MetricCard({
17
+ title,
18
+ value,
19
+ icon: Icon,
20
+ description,
21
+ loading,
22
+ error,
23
+ }: MetricCardProps) {
24
+ return (
25
+ <Card className="border-border/50 bg-card">
26
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
27
+ <CardTitle className="text-sm font-medium text-muted-foreground">
28
+ {title}
29
+ </CardTitle>
30
+ {Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
31
+ </CardHeader>
32
+ <CardContent>
33
+ {loading ? (
34
+ <Skeleton className="h-8 w-24" />
35
+ ) : error ? (
36
+ <p className="text-sm text-destructive">{error}</p>
37
+ ) : (
38
+ <>
39
+ <div className="text-2xl font-bold">
40
+ {typeof value === "number"
41
+ ? value.toLocaleString()
42
+ : (value ?? "-")}
43
+ </div>
44
+ {description && (
45
+ <p className="mt-1 text-xs text-muted-foreground">
46
+ {description}
47
+ </p>
48
+ )}
49
+ </>
50
+ )}
51
+ </CardContent>
52
+ </Card>
53
+ );
54
+ }
@@ -0,0 +1,49 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card.js";
4
+ import { cn } from "../utils.js";
5
+
6
+ export interface StatsCardProps {
7
+ title: string;
8
+ value: string;
9
+ icon: ComponentType<{ className?: string }>;
10
+ description: string;
11
+ trend?: { value: number; label?: string };
12
+ }
13
+
14
+ /** A metric card with an optional positive/negative percentage trend. */
15
+ export function StatsCard({
16
+ title,
17
+ value,
18
+ icon: Icon,
19
+ description,
20
+ trend,
21
+ }: StatsCardProps) {
22
+ return (
23
+ <Card className="border-border/50 bg-card shadow-sm">
24
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
25
+ <CardTitle className="text-sm font-medium text-muted-foreground">
26
+ {title}
27
+ </CardTitle>
28
+ <Icon className="h-4 w-4 text-muted-foreground" />
29
+ </CardHeader>
30
+ <CardContent>
31
+ <div className="text-2xl font-bold">{value}</div>
32
+ <p className="mt-1 text-xs text-muted-foreground">
33
+ {trend && (
34
+ <span
35
+ className={cn(
36
+ "mr-1 font-medium",
37
+ trend.value > 0 ? "text-foreground" : "text-destructive",
38
+ )}
39
+ >
40
+ {trend.value > 0 ? "+" : ""}
41
+ {trend.value}%{trend.label ? ` ${trend.label}` : ""}
42
+ </span>
43
+ )}
44
+ {description}
45
+ </p>
46
+ </CardContent>
47
+ </Card>
48
+ );
49
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ buildDashboardPanelGroups,
5
+ clampDashboardColumns,
6
+ columnExpansionForDropSlot,
7
+ movePanelToDropSlot,
8
+ removePanelFromLayout,
9
+ type DashboardLayoutPanel,
10
+ type DashboardDropSlot,
11
+ } from "./dashboard-layout.js";
12
+
13
+ type Panel = DashboardLayoutPanel & { kind?: "section" | "chart" };
14
+
15
+ function panel(id: string, width = 1): Panel {
16
+ return { id, width, kind: "chart" };
17
+ }
18
+
19
+ const options = { isSection: (item: Panel) => item.kind === "section" };
20
+
21
+ describe("dashboard layout", () => {
22
+ it("groups a section and balances persisted widths per visible row", () => {
23
+ const panels: Panel[] = [
24
+ panel("a"),
25
+ { id: "section", kind: "section", columns: 3 },
26
+ panel("b"),
27
+ panel("c"),
28
+ panel("d"),
29
+ ];
30
+ const groups = buildDashboardPanelGroups(panels, 2, options);
31
+
32
+ expect(
33
+ groups.map((group) =>
34
+ group.rows.map((row) => row.panels.map((item) => item.id)),
35
+ ),
36
+ ).toEqual([[["a"]], [["b", "c", "d"]]]);
37
+ expect(groups[1].columns).toBe(3);
38
+ });
39
+
40
+ it("removes without backfilling a later row", () => {
41
+ const next = removePanelFromLayout(
42
+ ["a", "b", "c", "d", "e"].map((id) => panel(id)),
43
+ "b",
44
+ 3,
45
+ options,
46
+ );
47
+ expect(next.map((item) => [item.id, item.width])).toEqual([
48
+ ["a", 2],
49
+ ["c", 1],
50
+ ["d", 2],
51
+ ["e", 1],
52
+ ]);
53
+ });
54
+
55
+ it("moves into a column slot and grows the target group as needed", () => {
56
+ const panels = ["a", "b", "c"].map((id) => panel(id));
57
+ const slot: DashboardDropSlot = {
58
+ type: "column",
59
+ groupKey: "intro",
60
+ rowIndex: 0,
61
+ columnIndex: 1,
62
+ };
63
+ const groups = buildDashboardPanelGroups(panels, 2, options);
64
+ expect(columnExpansionForDropSlot(groups, "c", slot)).toEqual({
65
+ columns: 3,
66
+ sectionPanelId: null,
67
+ });
68
+
69
+ const next = movePanelToDropSlot(panels, "c", slot, 2, options);
70
+ expect(next.map((item) => item.id)).toEqual(["a", "c", "b"]);
71
+ expect(next.map((item) => item.width)).toEqual([1, 1, 1]);
72
+ });
73
+
74
+ it("clamps malformed and out-of-range column counts", () => {
75
+ expect(clampDashboardColumns("4")).toBe(2);
76
+ expect(clampDashboardColumns(-3)).toBe(1);
77
+ expect(clampDashboardColumns(99)).toBe(6);
78
+ });
79
+ });