@columenlabs/lumen-ui 0.2.0 → 0.2.2

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.
@@ -143,7 +143,12 @@ export function connectionFields(domain, connections, read, taken) {
143
143
  // field on each side is picked; its group stays in the tree for that.
144
144
  if (!on) {
145
145
  sources.push({ id, name, state: 'off', detail: NOT_LINKED });
146
- groups.push({ id, label: name, color: LINK_COLOR, note: NOT_LINKED });
146
+ groups.push({
147
+ id,
148
+ label: name,
149
+ color: LINK_COLOR,
150
+ note: 'Pick a field on each side to link it',
151
+ });
147
152
  continue;
148
153
  }
149
154
  const { index, ok } = indexOf(to, on);
@@ -32,6 +32,7 @@ import { LumenViewCard } from './lumen-view-card.js';
32
32
  import { AddWidgetDialog, LayoutActions, LayoutChip, LumenSavedWidget, LumenWidget, cardFilterFields, cardTitle, cardsAfter, } from './lumen-widget.js';
33
33
  import { OriginLegend } from './origin-legend.js';
34
34
  import { IconButton, Page } from './page.js';
35
+ import { SourcedCard } from './sourced-card.js';
35
36
  import { LumenCard, SummaryTiles } from './summary-cards.js';
36
37
  import { Button } from './ui/button.js';
37
38
  import { ViewEditDialog } from './view-editor.js';
@@ -534,7 +535,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
534
535
  return {
535
536
  ...place,
536
537
  wide: isTableCard(c) && c.w === GRID_COLS && c.h === undefined,
537
- node: isTableCard(c) && c.id !== viewCardId ? (_jsx(LayoutTable, { card: c, base: joins.domain, config: config, rows: c.filter.length ? filterByTokens(rows, widgetFields, c.filter) : rows, load: scope(c.id, load), state: state, onDetails: onDetails, controls: controls, onLayout: onConfigChange ? (table) => putCard({ ...c, table }) : undefined })) : isTableCard(c) ? (_jsx(LumenView, { domain: domain, config: config, rows: viewRows, total: rows.length, groupBy: group.groupField, pivot: pivot, subgroup: group.sub, load: scope(c.id, load), state: state, title: c.title ?? config.name, selection: selection, onDetails: rowPick.onTap ?? onDetails, tapped: rowPick.tapped, groupActions: groupActions, controls: controls, inLayout: true, tools: viewTools, filter: viewFilter, filterCount: filter.tokens.length, strip: _jsx(ConnectOffer, { domain: domain, rows: rows, config: config, read: joins.read, cardKey: viewId ?? config.name, onConnect: onConfigChange ? connect : undefined }), link: cardLink(c.id, viewFollows, viewColor ? filter.tokens.length : 0), shown: c.controls, onColumns: onConfigChange && columnPicker !== false
538
+ node: c.source ? (_jsx(SourcedCard, { card: c, name: config.name, controls: controls })) : isTableCard(c) && c.id !== viewCardId ? (_jsx(LayoutTable, { card: c, base: joins.domain, config: config, rows: c.filter.length ? filterByTokens(rows, widgetFields, c.filter) : rows, load: scope(c.id, load), state: state, onDetails: onDetails, controls: controls, onLayout: onConfigChange ? (table) => putCard({ ...c, table }) : undefined })) : isTableCard(c) ? (_jsx(LumenView, { domain: domain, config: config, rows: viewRows, total: rows.length, groupBy: group.groupField, pivot: pivot, subgroup: group.sub, load: scope(c.id, load), state: state, title: c.title ?? config.name, selection: selection, onDetails: rowPick.onTap ?? onDetails, tapped: rowPick.tapped, groupActions: groupActions, controls: controls, inLayout: true, tools: viewTools, filter: viewFilter, filterCount: filter.tokens.length, strip: _jsx(ConnectOffer, { domain: domain, rows: rows, config: config, read: joins.read, cardKey: viewId ?? config.name, onConnect: onConfigChange ? connect : undefined }), link: cardLink(c.id, viewFollows, viewColor ? filter.tokens.length : 0), shown: c.controls, onColumns: onConfigChange && columnPicker !== false
538
539
  ? (columns) => onConfigChange({ ...config, columns })
539
540
  : undefined, onAddColumn: addColumn, sort: heldSort, onSort: setHeldSort })) : (_jsx(LumenSavedWidget, { widget: c, title: cardTitle(c, config.name), domain: domain, rows: narrow(rows, followed(c.id)), link: cardLink(c.id, followed(c.id)), fields: widgetFields, merge: mergeFlat, onDetails: onDetails, load: scope(c.id, load), tiles: 'tile' in c
540
541
  ? cardOf(c)
@@ -607,10 +608,12 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
607
608
  } })) : (onConfigChange && (_jsxs(_Fragment, { children: [changed && saveMode === 'document' && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: Undo2Icon, label: "Revert view", onClick: revert }), _jsx(IconButton, { icon: SaveIcon, label: "Save view (chips, sort, group)", variant: "default", onClick: () => onConfigChange?.(heldConfig) })] })), _jsx(IconButton, { icon: LayoutDashboardIcon, label: "Edit layout", onClick: () => {
608
609
  setDraft(saved);
609
610
  setLayout(true);
610
- } })] }))), children: [_jsx(LinksProvider, { links: links, children: _jsx(Scroller, { fit: fit, children: body }) }), _jsx(LumenDrawer, { open: open !== null, title: openTitle, onClose: () => setDetails(null), children: open !== null && (_jsx(DetailsBody, { domain: domain, row: open, columns: columns, chips: config.chips ?? [] })) }), onConfigChange && (_jsxs(_Fragment, { children: [_jsx(AddWidgetDialog, { open: adding, onOpenChange: setAdding, domain: domain, cards: cards, onAdd: (c) => {
611
+ } })] }))), children: [_jsx(LinksProvider, { links: links, children: _jsx(Scroller, { fit: fit, children: body }) }), _jsx(LumenDrawer, { open: open !== null, title: openTitle, onClose: () => setDetails(null), children: open !== null && (_jsx(DetailsBody, { domain: domain, row: open, columns: columns, chips: config.chips ?? [] })) }), onConfigChange && (_jsxs(_Fragment, { children: [_jsx(AddWidgetDialog, { open: adding, onOpenChange: setAdding, domain: domain, cards: cards, name: config.name, onAdd: (c) => {
611
612
  setAdding(false);
612
613
  // A second table starts as a copy of the view's layout, then goes its own way.
613
- putCard(isTableCard(c) && mainTable ? { ...c, table: tableLayoutOf(config) } : c);
614
+ putCard(isTableCard(c) && mainTable && !c.source
615
+ ? { ...c, table: tableLayoutOf(config) }
616
+ : c);
614
617
  }, onBuild: (c) => {
615
618
  setAdding(false);
616
619
  if (c.kind !== 'page')
@@ -144,11 +144,13 @@ export declare function draftOf<T>(styleId: string, domain: ViewDomain<T>, cards
144
144
  * of $1 a domain that draws curves. */
145
145
  export declare function useOffered<T>(domain: ViewDomain<T>): (styleId: string) => boolean;
146
146
  /** Add widget: the domain's pre-built widgets, or a kind to build. */
147
- export declare function AddWidgetDialog<T>({ open, onOpenChange, domain, cards, onAdd, onBuild, }: {
147
+ export declare function AddWidgetDialog<T>({ open, onOpenChange, domain, cards, onAdd, onBuild, name, }: {
148
148
  open: boolean;
149
149
  onOpenChange: (o: boolean) => void;
150
150
  domain: ViewDomain<T>;
151
151
  cards: ViewCard[];
152
+ /** The page's name, for the data picker's "this page" row. */
153
+ name?: string;
152
154
  /** Adds a ready card (a pre-built widget, or the view's tiles or the view itself). */
153
155
  onAdd: (card: ViewCard) => void;
154
156
  /** Opens Edit widget on a new card of a kind. */
@@ -175,6 +177,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
175
177
  op?: "between" | "eq" | "gt" | "lt" | undefined;
176
178
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
177
179
  }[];
180
+ source?: string | undefined;
181
+ connections?: {
182
+ to: string;
183
+ on: {
184
+ from: string;
185
+ to: string;
186
+ }[];
187
+ }[] | undefined;
178
188
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
179
189
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
180
190
  kind: "table";
@@ -230,6 +240,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
230
240
  op?: "between" | "eq" | "gt" | "lt" | undefined;
231
241
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
232
242
  }[];
243
+ source?: string | undefined;
244
+ connections?: {
245
+ to: string;
246
+ on: {
247
+ from: string;
248
+ to: string;
249
+ }[];
250
+ }[] | undefined;
233
251
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
234
252
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
235
253
  kind: "table";
@@ -257,6 +275,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
257
275
  op?: "between" | "eq" | "gt" | "lt" | undefined;
258
276
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
259
277
  }[];
278
+ source?: string | undefined;
279
+ connections?: {
280
+ to: string;
281
+ on: {
282
+ from: string;
283
+ to: string;
284
+ }[];
285
+ }[] | undefined;
260
286
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
261
287
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
262
288
  kind: "metric";
@@ -325,6 +351,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
325
351
  op?: "between" | "eq" | "gt" | "lt" | undefined;
326
352
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
327
353
  }[];
354
+ source?: string | undefined;
355
+ connections?: {
356
+ to: string;
357
+ on: {
358
+ from: string;
359
+ to: string;
360
+ }[];
361
+ }[] | undefined;
328
362
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
329
363
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
330
364
  kind: "metric";
@@ -387,6 +421,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
387
421
  op?: "between" | "eq" | "gt" | "lt" | undefined;
388
422
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
389
423
  }[];
424
+ source?: string | undefined;
425
+ connections?: {
426
+ to: string;
427
+ on: {
428
+ from: string;
429
+ to: string;
430
+ }[];
431
+ }[] | undefined;
390
432
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
391
433
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
392
434
  w: number;
@@ -416,6 +458,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
416
458
  op?: "between" | "eq" | "gt" | "lt" | undefined;
417
459
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
418
460
  }[];
461
+ source?: string | undefined;
462
+ connections?: {
463
+ to: string;
464
+ on: {
465
+ from: string;
466
+ to: string;
467
+ }[];
468
+ }[] | undefined;
419
469
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
420
470
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
421
471
  kind: "allocation";
@@ -443,6 +493,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
443
493
  op?: "between" | "eq" | "gt" | "lt" | undefined;
444
494
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
445
495
  }[];
496
+ source?: string | undefined;
497
+ connections?: {
498
+ to: string;
499
+ on: {
500
+ from: string;
501
+ to: string;
502
+ }[];
503
+ }[] | undefined;
446
504
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
447
505
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
448
506
  kind: "allocation";
@@ -470,6 +528,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
470
528
  op?: "between" | "eq" | "gt" | "lt" | undefined;
471
529
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
472
530
  }[];
531
+ source?: string | undefined;
532
+ connections?: {
533
+ to: string;
534
+ on: {
535
+ from: string;
536
+ to: string;
537
+ }[];
538
+ }[] | undefined;
473
539
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
474
540
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
475
541
  kind: "allocation";
@@ -500,6 +566,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
500
566
  op?: "between" | "eq" | "gt" | "lt" | undefined;
501
567
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
502
568
  }[];
569
+ source?: string | undefined;
570
+ connections?: {
571
+ to: string;
572
+ on: {
573
+ from: string;
574
+ to: string;
575
+ }[];
576
+ }[] | undefined;
503
577
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
504
578
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
505
579
  kind: "chart";
@@ -527,6 +601,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
527
601
  op?: "between" | "eq" | "gt" | "lt" | undefined;
528
602
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
529
603
  }[];
604
+ source?: string | undefined;
605
+ connections?: {
606
+ to: string;
607
+ on: {
608
+ from: string;
609
+ to: string;
610
+ }[];
611
+ }[] | undefined;
530
612
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
531
613
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
532
614
  kind: "chart";
@@ -552,6 +634,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
552
634
  op?: "between" | "eq" | "gt" | "lt" | undefined;
553
635
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
554
636
  }[];
637
+ source?: string | undefined;
638
+ connections?: {
639
+ to: string;
640
+ on: {
641
+ from: string;
642
+ to: string;
643
+ }[];
644
+ }[] | undefined;
555
645
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
556
646
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
557
647
  kind: "chart";
@@ -577,6 +667,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
577
667
  op?: "between" | "eq" | "gt" | "lt" | undefined;
578
668
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
579
669
  }[];
670
+ source?: string | undefined;
671
+ connections?: {
672
+ to: string;
673
+ on: {
674
+ from: string;
675
+ to: string;
676
+ }[];
677
+ }[] | undefined;
580
678
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
581
679
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
582
680
  w: number;
@@ -606,6 +704,14 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
606
704
  op?: "between" | "eq" | "gt" | "lt" | undefined;
607
705
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
608
706
  }[];
707
+ source?: string | undefined;
708
+ connections?: {
709
+ to: string;
710
+ on: {
711
+ from: string;
712
+ to: string;
713
+ }[];
714
+ }[] | undefined;
609
715
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
610
716
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
611
717
  kind: "page";
@@ -7,7 +7,7 @@ import { OUTLOOK_INDEXES, marketWindows, } from '@columenlabs/lumen-ui-types/mar
7
7
  // cards out; this file draws the card frame every card shares (its header, ↻ and ⋯ menu), each
8
8
  // widget, the Edit layout bar, Add widget (Pre-built and Build your own) and Edit widget.
9
9
  import { ALLOCATION_MAX, VIEW_CARD_KIND_NAMES, WIDGET_STYLES, controlOn, isDollarFormat, isRowCard, isTableCard, trendBuckets, viewCardKinds, viewCardSchema, viewWidgetCatalog, widgetStyleId, widgetStyleOf, } from '@columenlabs/lumen-ui-types/views';
10
- import { ActivityIcon, ArrowDownIcon, ArrowUpIcon, BarChart3Icon, CandlestickChartIcon, ChartLineIcon, ChartPieIcon, CheckIcon, CodeIcon, CopyIcon, EyeOffIcon, FoldVerticalIcon, FunnelIcon, GripVerticalIcon, InfoIcon, LayoutGridIcon, ListOrderedIcon, MoreHorizontalIcon, PackageIcon, PencilIcon, PlusIcon, RotateCcwIcon, Rows3Icon, SparklesIcon, SquareIcon, TableIcon, Trash2Icon, TrendingUpIcon, WrenchIcon, XIcon, } from 'lucide-react';
10
+ import { ActivityIcon, ArrowDownIcon, ArrowUpIcon, BarChart3Icon, CandlestickChartIcon, ChartLineIcon, ChartPieIcon, CheckIcon, CodeIcon, CopyIcon, DatabaseIcon, EyeOffIcon, FoldVerticalIcon, FunnelIcon, GripVerticalIcon, InfoIcon, LayoutGridIcon, ListOrderedIcon, MoreHorizontalIcon, PackageIcon, PencilIcon, PlusIcon, RotateCcwIcon, Rows3Icon, SparklesIcon, SquareIcon, TableIcon, Trash2Icon, TrendingUpIcon, WrenchIcon, XIcon, } from 'lucide-react';
11
11
  import { useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { useAssistant, useAssistantName } from './assistant-context.js';
13
13
  import { LumenConfirm } from './confirm.js';
@@ -15,6 +15,7 @@ import { FoldChevron, useFolded, usePageKey } from './fold.js';
15
15
  import { GrowthChart } from './growth-chart.js';
16
16
  import { useIsMobile } from './hooks/use-mobile.js';
17
17
  import { IconButton } from './icon-button.js';
18
+ import { useDomainReads } from './joins.js';
18
19
  import { cn } from './lib/utils.js';
19
20
  import { LumenBoundary } from './lumen-boundary.js';
20
21
  import { LumenTokenizedFilter, filterByTokens, useLumenFilter, } from './lumen-filter.js';
@@ -48,6 +49,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
48
49
  import { fieldOptions } from './view-formulas.js';
49
50
  import { computedOf, label, summaryCash, } from './view-model.js';
50
51
  import { WidgetControlsEditor } from './widget-controls.js';
52
+ import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
51
53
  import { dateFields } from './widget-settings.js';
52
54
  /** Each style's icon, by its id in `WIDGET_STYLES`; a style left out takes its kind's. */
53
55
  const STYLE_ICONS = {
@@ -513,13 +515,26 @@ function PickCard({ icon: Icon, name, note, added, disabled, onClick, }) {
513
515
  return (_jsxs(Button, { variant: "outline", disabled: disabled, onClick: onClick, className: "h-auto justify-start gap-3 p-3 text-left font-normal whitespace-normal", children: [_jsx("span", { className: "grid size-8 shrink-0 place-items-center rounded-md bg-primary/10 text-primary", children: _jsx(Icon, { className: "size-4" }) }), _jsxs("span", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("span", { className: "font-medium", children: name }), _jsx("span", { className: "text-xs text-muted-foreground", children: note })] }), added && (_jsxs(Badge, { variant: "secondary", className: "ml-auto font-normal", children: [_jsx(CheckIcon, {}), " Added"] }))] }));
514
516
  }
515
517
  /** Add widget: the domain's pre-built widgets, or a kind to build. */
516
- export function AddWidgetDialog({ open, onOpenChange, domain, cards, onAdd, onBuild, }) {
517
- const catalog = viewWidgetCatalog[domain.id] ?? [];
518
+ export function AddWidgetDialog({ open, onOpenChange, domain, cards, onAdd, onBuild, name = 'This page', }) {
519
+ // Step 1: the widget's main data, this page's rows unless another is picked.
520
+ const [data, setData] = useState(PAGE_SOURCE);
521
+ // biome-ignore lint/correctness/useExhaustiveDependencies: starts on this page each time it opens.
522
+ useEffect(() => setData(PAGE_SOURCE), [open]);
523
+ const pageLabel = `${name} (this page)`;
524
+ const dataOptions = useWidgetDataOptions(open, pageLabel);
525
+ const own = data !== PAGE_SOURCE;
526
+ const ids = useMemo(() => (own ? [data] : []), [own, data]);
527
+ const read = useDomainReads(ids, ids)[data];
528
+ const shapeDomain = (own ? (read?.domain ?? { id: data, rowKey: String, fields: [] }) : domain);
529
+ const sourced = (c) => (own ? { ...c, source: data } : c);
530
+ const catalog = own ? [] : (viewWidgetCatalog[domain.id] ?? []);
518
531
  const keys = new Set(domain.fields.map((f) => f.key));
519
- const hasRow = cards.some(isRowCard);
532
+ const hasRow = !own && cards.some(isRowCard);
520
533
  const full = cards.length >= 12;
521
- const offered = useOffered(domain);
522
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Add widget" }), _jsxs(DialogDescription, { children: ["Widgets use this view's rows and their own filter", full ? '. A page holds 12 cards at most.' : '.'] })] }), _jsxs(Tabs, { defaultValue: catalog.length ? 'prebuilt' : 'own', children: [_jsxs(TabsList, { children: [_jsxs(TabsTrigger, { value: "prebuilt", children: [_jsx(PackageIcon, {}), "Pre-built"] }), _jsxs(TabsTrigger, { value: "own", children: [_jsx(WrenchIcon, {}), "Build your own"] })] }), _jsx(TabsContent, { value: "prebuilt", className: dialogBody, children: catalog.length ? (_jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: catalog.map((it) => {
534
+ const offered = useOffered(shapeDomain);
535
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Add widget" }), _jsxs(DialogDescription, { children: ["Pick its data, then its shape", full ? '. A page holds 12 cards at most.' : '.'] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "add-widget-data", className: "text-xs font-medium text-muted-foreground", children: "Data" }), _jsx(LumenSelector, { id: "add-widget-data", icon: DatabaseIcon, label: "Widget data", search: true, value: data, options: dataOptions, onChange: setData }), own && read?.state !== 'ok' && (_jsx("p", { className: cn('text-xs', read?.state === 'loading' || !read ? 'text-muted-foreground' : 'text-destructive'), children: read?.state === 'loading' || !read
536
+ ? 'Reading its fields…'
537
+ : (read.detail ?? 'Its rows could not be read.') }))] }), _jsxs(Tabs, { defaultValue: catalog.length ? 'prebuilt' : 'own', children: [_jsxs(TabsList, { children: [!own && (_jsxs(TabsTrigger, { value: "prebuilt", children: [_jsx(PackageIcon, {}), "Pre-built"] })), _jsxs(TabsTrigger, { value: "own", children: [_jsx(WrenchIcon, {}), "Build your own"] })] }), _jsx(TabsContent, { value: "prebuilt", className: dialogBody, children: catalog.length ? (_jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: catalog.map((it) => {
523
538
  const added = cards.some((b) => b.id === it.widget.id);
524
539
  const fits = [
525
540
  ...('by' in it.widget ? [it.widget.by, it.widget.value] : []),
@@ -536,16 +551,16 @@ export function AddWidgetDialog({ open, onOpenChange, domain, cards, onAdd, onBu
536
551
  // A table goes on as it is; the page's first row of numbers shows the
537
552
  // view's cards, any other starts empty in Edit widget.
538
553
  const ready = w.id === 'table:table' || (w.id === 'metric:row' && !hasRow);
539
- return (_jsx(PickCard, { icon: cardIcon(w), name: w.name, note: w.note, disabled: full, onClick: () => ready
540
- ? onAdd(draftOf(w.id, domain, cards))
541
- : onBuild(w.id === 'metric:row'
554
+ return (_jsx(PickCard, { icon: cardIcon(w), name: w.name, note: w.note, disabled: full || (own && read?.state !== 'ok'), onClick: () => ready
555
+ ? onAdd(sourced(draftOf(w.id, shapeDomain, cards)))
556
+ : onBuild(sourced(w.id === 'metric:row'
542
557
  ? viewCardSchema.parse({
543
- ...draftOf(w.id, domain, cards),
558
+ ...draftOf(w.id, shapeDomain, cards),
544
559
  tiles: [],
545
560
  })
546
- : draftOf(w.id, domain, cards)) }, w.id));
561
+ : draftOf(w.id, shapeDomain, cards))) }, w.id));
547
562
  }) })] }, kind));
548
- }) })] })] }) }));
563
+ }) })] }, data)] }) }));
549
564
  }
550
565
  /** A page's cards after `action` on card `i`, or null for Edit (the caller opens a dialog). */
551
566
  export function cardsAfter(cards, i, action) {
@@ -0,0 +1,10 @@
1
+ import { type ViewWidget } from '@columenlabs/lumen-ui-types/views';
2
+ import { type CardControls } from './lumen-widget.js';
3
+ export declare function SourcedCard({ card, name, controls, }: {
4
+ card: ViewWidget & {
5
+ source: string;
6
+ };
7
+ /** The page's name, for a widget with no title of its own. */
8
+ name: string;
9
+ controls?: CardControls;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // A widget with its own main data (`source` on the card): it reads that domain or recipe itself
3
+ // (the same reads a link uses), joins its own links, and draws like any widget over those rows. It
4
+ // shares nothing of the page's rows; the page's chips reach it only by a key both carry.
5
+ import { isRowCard, isTableCard, sourceConfigOf, tilesOfCard, } from '@columenlabs/lumen-ui-types/views';
6
+ import { useMemo } from 'react';
7
+ import { useDomainReads, useJoins } from './joins.js';
8
+ import { filterByTokens } from './lumen-filter.js';
9
+ import { followedBy, followsText, keyFilterFields, useLinks } from './lumen-links.js';
10
+ import { mergeRows } from './lumen-view.js';
11
+ import { LumenViewCard } from './lumen-view-card.js';
12
+ import { LumenSavedWidget, cardFilterFields, cardTitle } from './lumen-widget.js';
13
+ import { LumenCard, SummaryTiles } from './summary-cards.js';
14
+ import { summaryCash, withViewFields } from './view-model.js';
15
+ /** A source read's state as the card's loading state. */
16
+ function loadOf(state, detail, label) {
17
+ const failed = state === 'failed' || state === 'off';
18
+ return {
19
+ loading: state === 'loading',
20
+ fetching: state === 'loading',
21
+ refreshing: false,
22
+ background: false,
23
+ error: failed ? new Error(`${label}: ${detail ?? 'could not be read'}`) : null,
24
+ stale: null,
25
+ refresh: async () => { },
26
+ tookMs: null,
27
+ cached: false,
28
+ at: null,
29
+ };
30
+ }
31
+ export function SourcedCard({ card, name, controls, }) {
32
+ const ids = useMemo(() => [card.source], [card.source]);
33
+ const read = useDomainReads(ids, ids)[card.source];
34
+ const base = (read?.domain ?? {
35
+ id: card.source,
36
+ rowKey: String,
37
+ fields: [],
38
+ });
39
+ const rows = (read?.rows ?? []);
40
+ const config = useMemo(() => sourceConfigOf(card, name), [card, name]);
41
+ const load = loadOf(read?.state ?? 'loading', read?.detail, read?.label ?? card.source);
42
+ if (isTableCard(card))
43
+ return (_jsx(LumenViewCard, { id: card.id, domain: base, config: config, rows: card.filter.length
44
+ ? filterByTokens(rows, cardFilterFields(base.fields), card.filter)
45
+ : rows, load: load, title: card.title ?? read?.label ?? name, controls: controls, shown: card.controls }));
46
+ return (_jsx(SourcedWidget, { card: card, base: base, rows: rows, load: load, label: read?.label ?? name, controls: controls }));
47
+ }
48
+ function SourcedWidget({ card, base, rows, load, label, controls, }) {
49
+ const config = useMemo(() => sourceConfigOf(card, label), [card, label]);
50
+ const joins = useJoins(base, rows, config);
51
+ const domain = useMemo(() => withViewFields(joins.domain, config), [joins.domain, config]);
52
+ const fields = useMemo(() => [...cardFilterFields(domain.fields), ...keyFilterFields(domain.keys)], [domain.fields, domain.keys]);
53
+ const links = useLinks();
54
+ const follows = followedBy(links?.follows(card.id) ?? [], fields, domain.keys);
55
+ const own = card.filter.length ? filterByTokens(rows, fields, card.filter, domain.text) : rows;
56
+ const drawn = follows.length ? filterByTokens(own, fields, follows, domain.text) : own;
57
+ const narrowed = card.filter.length > 0 || follows.length > 0;
58
+ const counted = domain.cash && !summaryCash(isRowCard(card) ? card : {}, narrowed)
59
+ ? drawn.filter((r) => !domain.cash?.(r))
60
+ : drawn;
61
+ const loading = load.loading;
62
+ return (_jsx(LumenSavedWidget, { widget: card, title: card.title ?? cardTitle(card, label), domain: domain, rows: drawn, link: links
63
+ ? {
64
+ color: links.colorOf(card.id),
65
+ onPick: (c) => links.setColor(card.id, c),
66
+ follows: followsText(follows),
67
+ }
68
+ : undefined, fields: fields, merge: (rs) => mergeRows(domain, rs, null), load: load, tiles: 'tile' in card ? (_jsx(LumenCard, { domain: domain, tile: card.tile, rows: counted, fields: fields, loading: loading, style: card.style, target: card.target, meter: card.meter, bands: card.bands })) : isRowCard(card) ? (_jsx(SummaryTiles, { domain: domain, tiles: tilesOfCard(config, card), rows: counted, fields: fields, loading: loading })) : null, controls: controls }));
69
+ }
@@ -49,4 +49,4 @@ export interface ViewEditDialogProps<T> {
49
49
  * only Fields and Settings; a type tried and left comes back as it was. The view's own table stays
50
50
  * a table.
51
51
  */
52
- export declare function ViewEditDialog<T>({ open, onOpenChange, domain: joined, config, rows, current, onSave, card, onSaveCard, fields: filterFields, viewTiles, read, viewId, editor, onDuplicate, onDelete, }: ViewEditDialogProps<T>): import("react").JSX.Element;
52
+ export declare function ViewEditDialog<T>({ open, onOpenChange, domain: pageDomain, config, rows: pageRows, current, onSave, card, onSaveCard, fields: pageFields, viewTiles, read, viewId, editor, onDuplicate, onDelete, }: ViewEditDialogProps<T>): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { CATALOG, FRAME_LINK, catalogKeys, linkColumns, readableDomain, } from '@columenlabs/lumen-ui-types/catalog';
3
- import { CONNECTED_COUNT, VIEW_CARD_KIND_NAMES, WIDGET_STYLES, cardsOf, connectedKey, inferConnection, isOwnTile, isRowCard, isTableCard, mainTableOf, tableConfigOf, tableLayoutOf, viewAggs, viewCardSchema, viewPageSizes, viewPaginationSchema, widgetStyleId, } from '@columenlabs/lumen-ui-types/views';
3
+ import { CONNECTED_COUNT, VIEW_CARD_KIND_NAMES, WIDGET_STYLES, cardsOf, connectedKey, customViewConfigSchema, inferConnection, isOwnTile, isRowCard, isTableCard, mainTableOf, sourceConfigOf, tableConfigOf, tableLayoutOf, viewAggs, viewCardSchema, viewPageSizes, viewPaginationSchema, widgetStyleId, } from '@columenlabs/lumen-ui-types/views';
4
4
  // The Edit view dialog (mock 2b): two panes. Left, a tree of every field the
5
5
  // view's rows reach, grouped by where it comes from (Holding, Symbol by topic, Account, Formulas),
6
6
  // ticked to show. Right, what the view does with them: column order (drag), shape, group, filter,
@@ -10,7 +10,7 @@ import { CONNECTED_COUNT, VIEW_CARD_KIND_NAMES, WIDGET_STYLES, cardsOf, connecte
10
10
  import { DndContext, KeyboardSensor, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core';
11
11
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
12
12
  import { CSS } from '@dnd-kit/utilities';
13
- import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, Columns3Icon, CopyIcon, DatabaseIcon, EllipsisIcon, FilterIcon, GripVerticalIcon, LayoutDashboardIcon, Link2Icon, MoveHorizontalIcon, PencilIcon, PlusIcon, RotateCcwIcon, SearchIcon, ShapesIcon, ShrinkIcon, SigmaIcon, SlidersHorizontalIcon, SmileIcon, SparklesIcon, ToggleRightIcon, Trash2Icon, Unlink2Icon, XIcon, } from 'lucide-react';
13
+ import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, Columns3Icon, CopyIcon, DatabaseIcon, EllipsisIcon, FilterIcon, GripVerticalIcon, LayoutDashboardIcon, Link2Icon, MoveHorizontalIcon, PencilIcon, PlusIcon, ReplaceIcon, RotateCcwIcon, SearchIcon, ShapesIcon, ShrinkIcon, SigmaIcon, SlidersHorizontalIcon, SmileIcon, SparklesIcon, StarIcon, TableIcon, ToggleRightIcon, Trash2Icon, Unlink2Icon, UnlinkIcon, XIcon, } from 'lucide-react';
14
14
  import { useEffect, useMemo, useRef, useState } from 'react';
15
15
  import { AddFormulaDialog } from './add-column.js';
16
16
  import { useAssistant, useAssistantName } from './assistant-context.js';
@@ -21,12 +21,13 @@ import { SaveButton } from './form-issues.js';
21
21
  import { useIsMobile } from './hooks/use-mobile.js';
22
22
  import { IconButton } from './icon-button.js';
23
23
  import { IconPicker } from './icon-picker.js';
24
- import { asOf, frameGroupId, useDomainReads, useDraftFrames, useFrameChoices } from './joins.js';
24
+ import { asOf, frameGroupId, rowsDomain, useDomainReads, useDraftFrames, useFrameChoices, } from './joins.js';
25
25
  import { cn } from './lib/utils.js';
26
26
  import { LumenTokenizedFilter, filterByTokens, tokenText, useLumenFilter, } from './lumen-filter.js';
27
27
  import { LinkPicker } from './lumen-links.js';
28
28
  import { LumenSelector } from './lumen-selector.js';
29
- import { CARD_COLUMNS, CARD_TITLE_MAX, cardTitle, draftOf, useOffered } from './lumen-widget.js';
29
+ import { lumenToast } from './lumen-toast.js';
30
+ import { CARD_COLUMNS, CARD_TITLE_MAX, cardFilterFields, cardTitle, draftOf, useOffered, } from './lumen-widget.js';
30
31
  import { useApp } from './shell.js';
31
32
  import { summaryCardIssues } from './summary-cards.js';
32
33
  import { Badge } from './ui/badge.js';
@@ -48,6 +49,7 @@ import { AGG_LABELS, FORMAT_LABELS, FormatChip, FormulaForm, MAX_FORMULAS, field
48
49
  import { VIEW_ICONS, lucideIconNames, shapeIcon, viewIcon } from './view-icons.js';
49
50
  import { FORMULAS_GROUP, canFilter, chipsOf, formulaBlanks, formulasOf, groupOf, hiddenOf, subgroupLabel, subgroupOf, subgroupsOf, summaryCash, takesFormulas, tilesOf, totalAggOf, withViewFields, } from './view-model.js';
50
51
  import { WidgetControlsEditor } from './widget-controls.js';
52
+ import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
51
53
  import { EditorRow as Row, WidgetFields, WidgetSettings, hasWidgetSettings, readsRows, slotKeys, widgetSlots, } from './widget-settings.js';
52
54
  import { widgetThumb } from './widget-thumbs.js';
53
55
  /** The field's own group and Total rows: a formula over the rolled-up inputs, or a share. */
@@ -60,6 +62,7 @@ const SHAPES = [
60
62
  { value: 'pivot', label: 'Pivot' },
61
63
  ];
62
64
  const NONE = '_none';
65
+ /** The main data picker's value for the page's own rows. */
63
66
  const NO_FRAMES = [];
64
67
  /** A recipe's (a linked the assistant frame's) group id starts with this (`recipe:fr_…`). */
65
68
  const FRAME_PREFIX = frameGroupId('');
@@ -123,10 +126,17 @@ const tableDraftOf = (config, card) => tableConfigOf(config, isTableCard(card)
123
126
  * only Fields and Settings; a type tried and left comes back as it was. The view's own table stays
124
127
  * a table.
125
128
  */
126
- export function ViewEditDialog({ open, onOpenChange, domain: joined, config, rows, current, onSave, card, onSaveCard, fields: filterFields = [], viewTiles, read, viewId, editor, onDuplicate, onDelete, }) {
129
+ export function ViewEditDialog({ open, onOpenChange, domain: pageDomain, config, rows: pageRows, current, onSave, card, onSaveCard, fields: pageFields = [], viewTiles, read, viewId, editor, onDuplicate, onDelete, }) {
127
130
  const isMobile = useIsMobile();
128
- const start = () => (card ? tableDraftOf(config, card) : config);
131
+ // A widget's main data (`source`): null is the page's rows, '' none yet (all cleared).
132
+ const [source, setSource] = useState(card?.source ?? null);
133
+ const start = () => card ? tableDraftOf(card.source ? sourceConfigOf(card, config.name) : config, card) : config;
129
134
  const [draft, setDraft] = useState(start);
135
+ const srcIds = useMemo(() => (open && source ? [source] : NO_FRAMES), [open, source]);
136
+ const srcRead = useDomainReads(srcIds, srcIds);
137
+ const srcNow = source ? srcRead[source] : undefined;
138
+ const joined = (source === null ? pageDomain : (srcNow?.domain ?? rowsDomain(source || 'none', [], [])));
139
+ const rows = (source === null ? pageRows : (srcNow?.rows ?? []));
130
140
  // Recipes: the assistant frames the draft attaches, their columns in the tree
131
141
  // before Save.
132
142
  const frameIds = draft.frames ?? NO_FRAMES;
@@ -156,6 +166,7 @@ export function ViewEditDialog({ open, onOpenChange, domain: joined, config, row
156
166
  if (open !== wasOpen) {
157
167
  setWasOpen(open);
158
168
  if (open) {
169
+ setSource(card?.source ?? null);
159
170
  setDraft(start());
160
171
  setWidget(card && !isTableCard(card) ? card : null);
161
172
  setFrame(card ?? null);
@@ -273,6 +284,8 @@ export function ViewEditDialog({ open, onOpenChange, domain: joined, config, row
273
284
  }
274
285
  : undefined;
275
286
  const domain = withViewFields(withLinks, draft);
287
+ // A widget's own filter reads the page's fields, or its own source's.
288
+ const filterFields = source === null ? pageFields : cardFilterFields(domain.fields);
276
289
  const formulas = formulasOf(draft);
277
290
  // Each formula's blank rows on the page's rows now, and why.
278
291
  const formulaKey = JSON.stringify(formulas);
@@ -348,6 +361,97 @@ export function ViewEditDialog({ open, onOpenChange, domain: joined, config, row
348
361
  description: w.note,
349
362
  thumb: widgetThumb(w.id),
350
363
  }));
364
+ // Main data (a widget only): change it, drop it (the next source moves
365
+ // up and links re-infer against it), make a linked one the main, or clear all. Each has Undo.
366
+ const keysOf = (id) => (id.startsWith(FRAME_LINK) ? [] : catalogKeys(id));
367
+ const labelOf = (id) => id.startsWith(FRAME_LINK)
368
+ ? ((choices.data ?? []).find((f) => `${FRAME_LINK}${f.id}` === id)?.name ?? 'Recipe')
369
+ : (allRead[id]?.label ?? CATALOG[id]?.label ?? id);
370
+ const pageLabel = `${config.name} (this page)`;
371
+ const mainLabel = source === null ? pageLabel : source ? (srcNow?.label ?? labelOf(source)) : '';
372
+ // Everything the widget joins besides its main data: its links, and recipes attached by key.
373
+ const others = () => [
374
+ ...connections.map((c) => c.to),
375
+ ...frameIds.map((id) => `${FRAME_LINK}${id}`),
376
+ ];
377
+ const relink = (main, ids) => ids
378
+ .filter((x) => x !== main)
379
+ .slice(0, 4)
380
+ .map((to) => ({ to, on: inferConnection(keysOf(main), keysOf(to)) ?? [] }));
381
+ const rebuild = useRef(false);
382
+ const changeMain = (next, conns, message) => {
383
+ if (!card)
384
+ return;
385
+ const before = { source, draft, widget, tokens };
386
+ setSource(next);
387
+ setDraft(next === null
388
+ ? tableDraftOf(config, card)
389
+ : {
390
+ ...customViewConfigSchema.parse({ connections: conns }),
391
+ name: draft.name,
392
+ cards: draft.cards,
393
+ });
394
+ setTokens([]);
395
+ rebuild.current = true;
396
+ lumenToast.undo(message, () => {
397
+ setSource(before.source);
398
+ setDraft(before.draft);
399
+ setWidget(before.widget);
400
+ setTokens(before.tokens);
401
+ });
402
+ };
403
+ const dropMain = () => {
404
+ const rest = others();
405
+ const next = rest[0];
406
+ if (next)
407
+ changeMain(next, relink(next, rest), `${mainLabel} dropped. ${labelOf(next)} is the main data now.`);
408
+ else
409
+ changeMain('', [], `${mainLabel} dropped.`);
410
+ };
411
+ const makeMain = (to) => {
412
+ const old = source ?? (readableDomain(pageDomain.id) ? pageDomain.id : null);
413
+ const rest = [...others().filter((x) => x !== to), ...(old ? [old] : [])];
414
+ changeMain(to, relink(to, rest), `${labelOf(to)} is the main data now.`);
415
+ };
416
+ const pickMain = (to) => to === PAGE_SOURCE
417
+ ? changeMain(null, [], `${pageLabel} is the main data now.`)
418
+ : changeMain(to, relink(to, others()), `${labelOf(to)} is the main data now.`);
419
+ const clearAll = () => changeMain('', [], 'All data cleared.');
420
+ const dropLink = (to) => {
421
+ const before = draft;
422
+ unlink(to);
423
+ lumenToast.undo(`${labelOf(to)} dropped.`, () => setDraft(before));
424
+ };
425
+ const dropRecipe = (id) => {
426
+ const before = draft;
427
+ removeFrame(id);
428
+ lumenToast.undo(`${labelOf(`${FRAME_LINK}${id}`)} dropped.`, () => setDraft(before));
429
+ };
430
+ // The main data picker: this page's rows, the app's live domains, the person's recipes.
431
+ const dataOptions = useWidgetDataOptions(open && !!card, pageLabel);
432
+ const mainOptions = card ? dataOptions.filter((o) => o.value !== (source ?? PAGE_SOURCE)) : [];
433
+ // A new main data's fields: a widget whose fields it lacks starts afresh once its fields load.
434
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs when the new main data's fields arrive.
435
+ useEffect(() => {
436
+ if (!rebuild.current)
437
+ return;
438
+ if (source && srcNow?.state !== 'ok')
439
+ return;
440
+ rebuild.current = false;
441
+ if (!widget)
442
+ return;
443
+ const keys = new Set(domain.fields.map((f) => f.key));
444
+ const fits = widgetSlots(widget, domain).every((sl) => sl.keys.every((k) => keys.has(k)));
445
+ if (fits)
446
+ return;
447
+ const kept = Object.fromEntries(KEPT.filter((k) => widget[k] !== undefined).map((k) => [k, widget[k]]));
448
+ // A shape the new data cannot fill (a number with no number field) stays as it is, and Save
449
+ // says which field is missing.
450
+ try {
451
+ setWidget({ ...draftOf(styleId, domain, []), ...kept });
452
+ }
453
+ catch { }
454
+ }, [source, srcNow?.state, domain.fields.length]);
351
455
  // A widget's rows: the view's, narrowed by its own filter, with or without cash.
352
456
  const widgetRows = tokens.length ? filterByTokens(rows, filterFields, tokens, domain.text) : rows;
353
457
  const cashRows = widget && isRowCard(widget) && domain.cash && !summaryCash(widget, tokens.length > 0)
@@ -368,9 +472,25 @@ export function ViewEditDialog({ open, onOpenChange, domain: joined, config, row
368
472
  why: (f) => (slot.accepts(f) ? null : slot.why),
369
473
  }
370
474
  : { on: (k) => shownKeys.includes(k), set: setShown, why: () => null };
475
+ // A widget on its own data keeps it and its links on the card; the page's stay as they were.
476
+ const sourcePart = source
477
+ ? { source, connections: draft.connections ?? [] }
478
+ : { source: undefined, connections: undefined };
479
+ const viewPart = (next) => source !== null
480
+ ? {
481
+ formulas: config.formulas,
482
+ connections: config.connections,
483
+ ...(config.frames ? { frames: config.frames } : {}),
484
+ }
485
+ : {
486
+ formulas: next.formulas,
487
+ connections: next.connections,
488
+ ...(next.frames ? { frames: next.frames } : {}),
489
+ };
371
490
  const parsed = widget
372
491
  ? viewCardSchema.safeParse({
373
492
  ...widget,
493
+ ...sourcePart,
374
494
  title: title.trim() || null,
375
495
  // A widget's own filter is its own: it never shares with the colour group.
376
496
  filter: readsRows(widget) ? tokens.map(({ link: _, ...t }) => t) : [],
@@ -385,6 +505,16 @@ export function ViewEditDialog({ open, onOpenChange, domain: joined, config, row
385
505
  setSections((s) => new Set(s).add(id));
386
506
  };
387
507
  const issues = [
508
+ ...(source === ''
509
+ ? [
510
+ {
511
+ where: 'Data',
512
+ message: 'Pick the main data',
513
+ target: 'view-editor-main-data',
514
+ reveal: () => setTab('fields'),
515
+ },
516
+ ]
517
+ : []),
388
518
  ...(!card && !draft.name.trim()
389
519
  ? [
390
520
  {
@@ -463,11 +593,7 @@ export function ViewEditDialog({ open, onOpenChange, domain: joined, config, row
463
593
  }
464
594
  if (parsed) {
465
595
  if (parsed.success)
466
- onSaveCard?.(parsed.data, {
467
- formulas: draft.formulas,
468
- connections: draft.connections,
469
- ...(draft.frames ? { frames: draft.frames } : {}),
470
- });
596
+ onSaveCard?.(parsed.data, viewPart(draft));
471
597
  }
472
598
  else if (frame) {
473
599
  // A table of its own: the frame, its name and its layout.
@@ -476,25 +602,32 @@ export function ViewEditDialog({ open, onOpenChange, domain: joined, config, row
476
602
  onSaveCard?.(viewCardSchema.parse({
477
603
  ...Object.fromEntries(KEPT.filter((k) => frame[k] !== undefined).map((k) => [k, frame[k]])),
478
604
  ...(own?.controls ? { controls: own.controls } : {}),
605
+ ...sourcePart,
479
606
  kind: 'table',
480
607
  style: 'table',
481
608
  title: title.trim() || null,
482
609
  filter: [],
483
610
  table: tableLayoutOf(next),
484
- }), {
485
- formulas: next.formulas,
486
- connections: next.connections,
487
- ...(next.frames ? { frames: next.frames } : {}),
488
- });
611
+ }), viewPart(next));
489
612
  }
490
613
  onOpenChange(false);
491
614
  };
492
- const tree = pick ? (_jsx(FieldTree, { domain: domain, draft: draft, picked: pick.on, onPick: pick.set, why: pick.why, formulaProblems: formulaProblems, onEditFormula: edit, onRemoveFormula: removeFormula, onAddFormula: addFormula, deskDomains: deskDomains, onLink: link, onUnlink: unlink, linkPairs: {
615
+ const tree = pick ? (_jsx(FieldTree, { domain: domain, draft: draft, picked: pick.on, onPick: pick.set, why: pick.why, formulaProblems: formulaProblems, onEditFormula: edit, onRemoveFormula: removeFormula, onAddFormula: addFormula, deskDomains: deskDomains, onLink: link, onUnlink: card ? dropLink : unlink, main: card
616
+ ? {
617
+ label: mainLabel,
618
+ empty: source === '',
619
+ options: mainOptions,
620
+ onPick: pickMain,
621
+ onDrop: dropMain,
622
+ onClear: clearAll,
623
+ onMakeMain: makeMain,
624
+ }
625
+ : undefined, linkPairs: {
493
626
  cardFields: unlinked.fields.filter((f) => f.group !== FORMULAS_GROUP),
494
627
  fieldsOf: (to) => allRead[to]?.domain.fields ?? [],
495
628
  onOf: (to) => connections.find((c) => c.to === to)?.on ?? [],
496
629
  set: setPairs,
497
- }, deskLabel: app?.label ?? null, recipes: recipes, recipeNote: choices.isError ? `Recipes did not load: ${choices.error.message}` : null, onAddRecipe: addRecipe, recipeByKey: byKey, onRemoveRecipe: removeFrame, onNewRecipe: askRecipe })) : (_jsx("p", { className: "px-1.5 text-sm text-muted-foreground", children: widget?.kind === 'chart' && (widget.style === 'price' || widget.style === 'market')
630
+ }, deskLabel: app?.label ?? null, recipes: recipes, recipeNote: choices.isError ? `Recipes did not load: ${choices.error.message}` : null, onAddRecipe: addRecipe, recipeByKey: byKey, onRemoveRecipe: card ? dropRecipe : removeFrame, onNewRecipe: askRecipe })) : (_jsx("p", { className: "px-1.5 text-sm text-muted-foreground", children: widget?.kind === 'chart' && (widget.style === 'price' || widget.style === 'market')
498
631
  ? 'This chart reads its symbols, not fields.'
499
632
  : 'This widget takes no fields.' }));
500
633
  const treeHint = widget ? (slot && (_jsxs("div", { className: "flex flex-col gap-2 px-1.5 pb-2", children: [slots.length > 1 && (_jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "justify-self-start", "aria-label": "Fill", value: slot.id, onValueChange: (v) => v && setSlotId(v), children: slots.map((s) => (_jsx(ToggleGroupItem, { value: s.id, className: "px-2.5", children: s.label }, s.id))) })), _jsx("p", { className: "text-xs text-muted-foreground", children: slot.max === 1
@@ -577,8 +710,7 @@ function LinkPairs({ label, on, cardFields, fields, onChange, }) {
577
710
  const row = (p, i) => (_jsxs("div", { className: "flex min-w-0 items-center gap-1", children: [_jsx(LumenSelector, { label: "This card's field", size: "sm", className: "min-w-0 flex-1", placeholder: "This card", search: true, value: p.from || null, options: mine, onChange: (v) => put({ ...p, from: v }, i) }), _jsx("span", { className: "text-xs text-muted-foreground", children: "=" }), _jsx(LumenSelector, { label: `${label}'s field`, size: "sm", className: "min-w-0 flex-1", placeholder: theirs.length ? label : 'Loading…', search: true, disabled: !theirs.length, value: p.to || null, options: theirs, onChange: (v) => put({ ...p, to: v }, i) }), i !== null && (_jsx(IconButton, { icon: XIcon, size: "icon-xs", label: "Remove this pair", onClick: () => onChange(on.filter((_, j) => j !== i)) }))] }, i ?? 'new'));
578
711
  return (_jsxs("div", { className: "flex flex-col gap-1 px-1 py-1", children: [on.map((p, i) => row(p, i)), open && row(open, null), !open && on.length < MAX_PAIRS && (_jsxs(Button, { size: "xs", variant: "ghost", className: "self-start text-primary", onClick: () => setPart({ from: '', to: '' }), children: [_jsx(PlusIcon, {}), "Field pair"] }))] }));
579
712
  }
580
- /** The right pane: every field by group and topic, ticked to show it as a column. */
581
- function FieldTree({ domain, draft, picked, onPick, why, formulaProblems, onEditFormula, onRemoveFormula, onAddFormula, deskDomains = [], onLink, onUnlink, deskLabel, recipes = [], recipeNote, onAddRecipe, recipeByKey = () => false, onRemoveRecipe, onNewRecipe, linkPairs, }) {
713
+ function FieldTree({ domain, draft, picked, onPick, why, formulaProblems, onEditFormula, onRemoveFormula, onAddFormula, deskDomains = [], onLink, onUnlink, deskLabel, recipes = [], recipeNote, onAddRecipe, recipeByKey = () => false, onRemoveRecipe, onNewRecipe, linkPairs, main, }) {
582
714
  const assistantName = useAssistantName();
583
715
  const [query, setQuery] = useState('');
584
716
  // New recipe's box, open while not null.
@@ -655,32 +787,41 @@ function FieldTree({ domain, draft, picked, onPick, why, formulaProblems, onEdit
655
787
  const box = (_jsx(Checkbox, { checked: picked(f.key), disabled: !!off, onCheckedChange: (v) => onPick(f.key, v === true), "aria-label": `Use ${f.label}` }));
656
788
  return (_jsxs("div", { className: cn('flex min-h-7 items-center gap-2 rounded-md px-1 hover:bg-accent/60', off && 'text-muted-foreground'), children: [_jsxs(Label, { className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1 text-[0.8125rem] font-normal", children: [off ? _jsx(WhyOff, { note: off, children: box }) : box, _jsx("span", { className: "truncate", children: f.label }), f.note && (_jsx(Badge, { variant: "secondary", className: "font-normal", children: f.note })), fi >= 0 && formulaProblems[fi] && (_jsx(Badge, { variant: "destructive", className: "font-normal", children: "fix" }))] }), _jsx("span", { className: "flex w-8 shrink-0 justify-end", children: _jsx(FormatChip, { format: f.format, icon: f.icon }) }), _jsx("code", { className: "w-28 shrink-0 truncate font-mono text-[0.7rem] text-muted-foreground max-md:hidden", title: f.formula ?? f.key, children: fi >= 0 ? formulas[fi]?.expr || 'no expression yet' : f.key }), fi >= 0 && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: PencilIcon, size: "icon-xs", label: `Edit formula ${f.label}`, onClick: () => onEditFormula(f.key) }), _jsx(LumenConfirm, { title: `Remove ${f.label}?`, description: "The formula and its column leave this view when you save.", action: "Remove", onConfirm: () => onRemoveFormula(f.key), children: _jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Remove formula ${f.label}` }) })] }))] }, f.key));
657
789
  };
658
- return (_jsxs("div", { className: "flex flex-col gap-px", "data-testid": "view-fields", children: [_jsxs("div", { className: "mb-1 flex items-center gap-1.5", children: [_jsxs(InputGroup, { className: "min-w-0 flex-1 bg-background", children: [_jsx(InputGroupAddon, { children: _jsx(SearchIcon, {}) }), _jsx(InputGroupInput, { type: "search", className: "[&::-webkit-search-cancel-button]:hidden", "aria-label": "Search fields", placeholder: `Search ${domain.fields.length} fields`, value: query, onChange: (e) => setQuery(e.target.value) }), query && (_jsx(InputGroupAddon, { align: "inline-end", children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": "Clear search", onClick: () => setQuery(''), children: _jsx(XIcon, {}) }) }))] }), dataOptions.length > 0 && (_jsx(LumenSelector, { trigger: "chip", icon: PlusIcon, placeholder: "Data", label: "Add data", align: "end", search: true, value: null, options: dataOptions, empty: "Nothing else to add", onChange: addData }))] }), recipeNote && _jsx("p", { className: "px-1.5 pb-1 text-xs text-destructive", children: recipeNote }), wish !== null && onNewRecipe && (_jsxs("div", { className: "mb-1 flex flex-col gap-1.5 rounded-md border bg-background p-2", children: [_jsx(Label, { htmlFor: "new-recipe", className: "text-xs font-medium", children: "New recipe" }), _jsx(Textarea, { id: "new-recipe", autoFocus: true, rows: 2, value: wish, placeholder: "How far each symbol is off its 52-week high", onChange: (e) => setWish(e.target.value), className: "min-h-14 text-sm" }), _jsxs("p", { className: "text-xs text-muted-foreground", children: ["Saves this widget, then opens ", assistantName, " with your words to send. Its columns join here by ", Object.keys(domain.keys ?? {}).join(' or '), "."] }), _jsxs("div", { className: "flex justify-end gap-1.5", children: [_jsx(Button, { size: "xs", variant: "ghost", onClick: () => setWish(null), children: "Cancel" }), _jsxs(Button, { size: "xs", disabled: !wish.trim(), onClick: () => onNewRecipe(wish), children: [_jsx(SparklesIcon, {}), "Save and ask ", assistantName] })] })] })), groupsOf(domain).map((g, gi) => {
659
- const all = domain.fields.filter((f) => groupOf(domain, f) === g.id);
660
- const fields = all.filter(match);
661
- const formulasGroup = g.id === FORMULAS_GROUP;
662
- const linkTo = g.id.startsWith(LINK_PREFIX) ? g.id.slice(LINK_PREFIX.length) : null;
663
- const recipe = g.id.startsWith(FRAME_PREFIX) ? g.id.slice(FRAME_PREFIX.length) : null;
664
- if (q && !fields.length)
665
- return null;
666
- if (!all.length && !formulasGroup && !linkTo)
667
- return null;
668
- const color = g.color ?? PLAIN;
669
- // A link starts open, so its pairs and fields show where they are picked.
670
- const open = linkTo
671
- ? !!q || (toggled.get(`g:${g.id}`) ?? true)
672
- : isOpen(`g:${g.id}`, all) || (formulasGroup && !all.length);
673
- const topics = [...new Set(fields.map((f) => f.topic ?? ''))];
674
- return (_jsxs(Collapsible, { open: open, onOpenChange: () => flip(`g:${g.id}`, open), children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "h-7 min-w-0 flex-1 justify-start gap-2 px-1.5 text-[0.8125rem] font-semibold", children: [_jsx(ChevronRightIcon, { className: cn('text-muted-foreground transition-transform', open && 'rotate-90') }), _jsx("span", { className: "size-2.5 shrink-0 rounded-[3px]", style: { background: color } }), formulasGroup && _jsx(SigmaIcon, { className: "text-muted-foreground" }), linkTo && _jsx(Link2Icon, { className: "text-muted-foreground" }), recipe && _jsx(SparklesIcon, { className: "text-muted-foreground" }), _jsx("span", { className: "truncate", children: g.label }), gi === 0 && !linkTo && !recipe && !formulasGroup && (_jsx(Badge, { variant: "secondary", className: "h-4 px-1.5 text-[0.6875rem] font-normal", children: "Rows" })), _jsxs("span", { className: "num ml-auto text-xs font-normal text-muted-foreground", children: [all.filter((f) => picked(f.key)).length, "/", all.length] })] }) }), linkTo && onUnlink && (_jsx(LumenConfirm, { title: `Unlink ${g.label}?`, description: "Its columns leave this widget when you save.", action: "Unlink", onConfirm: () => onUnlink(linkTo), children: _jsx(IconButton, { icon: Unlink2Icon, size: "icon-xs", label: `Unlink ${g.label}` }) })), recipe && onRemoveRecipe && (_jsx(LumenConfirm, { title: `Remove ${g.label}?`, description: `Its columns leave this widget when you save. The recipe stays in ${assistantName}.`, action: "Remove", onConfirm: () => onRemoveRecipe(recipe), children: _jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Remove ${g.label}` }) }))] }), _jsxs(CollapsibleContent, { className: "ml-[0.9rem] flex flex-col gap-px border-l-2 pl-2", style: { borderColor: guide(color) }, children: [g.note && !q && _jsx("p", { className: "px-1 py-1 text-xs text-muted-foreground", children: g.note }), linkTo && linkPairs && !q && (_jsx(LinkPairs, { label: g.label, on: linkPairs.onOf(linkTo), cardFields: linkPairs.cardFields, fields: linkPairs.fieldsOf(linkTo), onChange: (on) => linkPairs.set(linkTo, on) })), topics.map((t) => {
675
- const inTopic = fields.filter((f) => (f.topic ?? '') === t);
676
- if (!t)
677
- return inTopic.map(leaf);
678
- const src = g.sources?.[t];
679
- const id = `t:${g.id}:${t}`;
680
- const topicOpen = isOpen(id, all.filter((f) => f.topic === t));
681
- return (_jsxs(Collapsible, { open: topicOpen, onOpenChange: () => flip(id, topicOpen), children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "h-7 w-full justify-start gap-1.5 px-1 text-[0.8125rem] font-semibold", children: [_jsx(ChevronRightIcon, { className: cn('text-muted-foreground transition-transform', topicOpen && 'rotate-90') }), _jsx("span", { className: "truncate", children: t }), _jsxs("span", { className: "num text-xs font-normal text-muted-foreground", children: [inTopic.filter((f) => picked(f.key)).length, "/", inTopic.length] }), src && (_jsxs("span", { className: "ml-auto flex min-w-0 items-center gap-1.5 text-xs font-normal text-muted-foreground", children: [_jsx("span", { className: "size-1.5 shrink-0 rounded-full", style: { background: src.color } }), _jsx("span", { className: "font-medium", style: { color: src.color }, children: src.name }), src.detail && (_jsxs("span", { className: "truncate max-md:hidden", children: ["\u00B7 ", src.detail] }))] }))] }) }), _jsx(CollapsibleContent, { className: "ml-[0.7rem] flex flex-col gap-px border-l-2 pl-2", style: { borderColor: guide(src?.color ?? PLAIN) }, children: inTopic.map(leaf) })] }, t));
682
- }), formulasGroup && onAddFormula && !q && (_jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 justify-start gap-2 px-1 font-normal text-muted-foreground", onClick: onAddFormula, children: [_jsx(PlusIcon, {}), "Add formula"] }))] })] }, g.id));
683
- })] }));
790
+ return (_jsxs("div", { className: "flex flex-col gap-px", "data-testid": "view-fields", children: [_jsxs("div", { className: cn('mb-1 flex items-center gap-1.5', main?.empty && 'hidden'), children: [_jsxs(InputGroup, { className: "min-w-0 flex-1 bg-background", children: [_jsx(InputGroupAddon, { children: _jsx(SearchIcon, {}) }), _jsx(InputGroupInput, { type: "search", className: "[&::-webkit-search-cancel-button]:hidden", "aria-label": "Search fields", placeholder: `Search ${domain.fields.length} fields`, value: query, onChange: (e) => setQuery(e.target.value) }), query && (_jsx(InputGroupAddon, { align: "inline-end", children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": "Clear search", onClick: () => setQuery(''), children: _jsx(XIcon, {}) }) }))] }), dataOptions.length > 0 && !main?.empty && (_jsx(LumenSelector, { trigger: "chip", icon: PlusIcon, placeholder: "Data", label: "Add data", align: "end", search: true, value: null, options: dataOptions, empty: "Nothing else to add", onChange: addData })), main && !main.empty && (_jsx(LumenConfirm, { title: "Clear all data?", description: "Every source leaves this widget. Pick its main data again, or Undo.", action: "Clear all", onConfirm: main.onClear, children: _jsx(IconButton, { icon: Trash2Icon, size: "icon-xs", label: "Clear all data" }) }))] }), recipeNote && _jsx("p", { className: "px-1.5 pb-1 text-xs text-destructive", children: recipeNote }), wish !== null && onNewRecipe && (_jsxs("div", { className: "mb-1 flex flex-col gap-1.5 rounded-md border bg-background p-2", children: [_jsx(Label, { htmlFor: "new-recipe", className: "text-xs font-medium", children: "New recipe" }), _jsx(Textarea, { id: "new-recipe", autoFocus: true, rows: 2, value: wish, placeholder: "How far each symbol is off its 52-week high", onChange: (e) => setWish(e.target.value), className: "min-h-14 text-sm" }), _jsxs("p", { className: "text-xs text-muted-foreground", children: ["Saves this widget, then opens ", assistantName, " with your words to send. Its columns join here by ", Object.keys(domain.keys ?? {}).join(' or '), "."] }), _jsxs("div", { className: "flex justify-end gap-1.5", children: [_jsx(Button, { size: "xs", variant: "ghost", onClick: () => setWish(null), children: "Cancel" }), _jsxs(Button, { size: "xs", disabled: !wish.trim(), onClick: () => onNewRecipe(wish), children: [_jsx(SparklesIcon, {}), "Save and ask ", assistantName] })] })] })), main?.empty && (_jsxs("div", { className: "flex flex-col items-center gap-2 rounded-md border border-dashed px-3 py-6 text-center", children: [_jsx(DatabaseIcon, { className: "size-5 text-muted-foreground" }), _jsx("p", { className: "text-sm font-medium", children: "Pick the main data" }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Its rows are what this widget shows." }), _jsx(LumenSelector, { id: "view-editor-main-data", trigger: "chip", icon: DatabaseIcon, placeholder: "Main data", label: "Pick the main data", search: true, value: null, options: main.options, empty: "No data to pick", onChange: main.onPick })] })), !main?.empty &&
791
+ groupsOf(domain).map((g, gi) => {
792
+ const all = domain.fields.filter((f) => groupOf(domain, f) === g.id);
793
+ const fields = all.filter(match);
794
+ const formulasGroup = g.id === FORMULAS_GROUP;
795
+ const linkTo = g.id.startsWith(LINK_PREFIX) ? g.id.slice(LINK_PREFIX.length) : null;
796
+ const rowsGroup = gi === 0 && !linkTo && !formulasGroup;
797
+ const recipe = !rowsGroup && g.id.startsWith(FRAME_PREFIX) ? g.id.slice(FRAME_PREFIX.length) : null;
798
+ // Not linked: no field pairs yet, so it adds nothing; its fields wait greyed out.
799
+ const unlinked = !!linkTo && !all.length && !(linkPairs?.onOf(linkTo).length ?? 0);
800
+ if (q && !fields.length)
801
+ return null;
802
+ if (!all.length && !formulasGroup && !linkTo)
803
+ return null;
804
+ const color = g.color ?? PLAIN;
805
+ // A link starts open, so its pairs and fields show where they are picked.
806
+ const open = linkTo
807
+ ? !!q || (toggled.get(`g:${g.id}`) ?? true)
808
+ : isOpen(`g:${g.id}`, all) || (formulasGroup && !all.length);
809
+ const topics = [...new Set(fields.map((f) => f.topic ?? ''))];
810
+ return (_jsxs(Collapsible, { open: open, onOpenChange: () => flip(`g:${g.id}`, open), children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "h-7 min-w-0 flex-1 justify-start gap-2 px-1.5 text-[0.8125rem] font-semibold", children: [_jsx(ChevronRightIcon, { className: cn('text-muted-foreground transition-transform', open && 'rotate-90') }), _jsx("span", { className: "size-2.5 shrink-0 rounded-[3px]", style: { background: color } }), formulasGroup && _jsx(SigmaIcon, { className: "text-muted-foreground" }), linkTo &&
811
+ (unlinked ? (_jsx(UnlinkIcon, { className: "text-destructive" })) : (_jsx(Link2Icon, { className: "text-muted-foreground" }))), recipe && _jsx(SparklesIcon, { className: "text-muted-foreground" }), _jsx("span", { className: "truncate", children: rowsGroup && main ? main.label : g.label }), rowsGroup && (_jsx(Badge, { variant: "secondary", className: "h-4 px-1.5 text-[0.6875rem] font-normal", children: "Rows" })), _jsxs("span", { className: "num ml-auto text-xs font-normal text-muted-foreground", children: [all.filter((f) => picked(f.key)).length, "/", all.length] })] }) }), rowsGroup && main && (_jsxs(_Fragment, { children: [_jsx(LumenSelector, { trigger: "icon", icon: ReplaceIcon, label: "Change the main data", align: "end", search: true, value: null, options: main.options, empty: "No other data", onChange: main.onPick }), _jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Drop ${main.label}`, onClick: main.onDrop })] })), main && (linkTo || recipe) && (_jsx(IconButton, { icon: StarIcon, size: "icon-xs", label: `Make ${g.label} the main data`, onClick: () => main.onMakeMain(linkTo ?? `${FRAME_LINK}${recipe}`) })), main && linkTo && onUnlink && (_jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Drop ${g.label}`, onClick: () => onUnlink(linkTo) })), main && recipe && onRemoveRecipe && (_jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Drop ${g.label}`, onClick: () => onRemoveRecipe(recipe) })), !main && linkTo && onUnlink && (_jsx(LumenConfirm, { title: `Unlink ${g.label}?`, description: "Its columns leave this widget when you save.", action: "Unlink", onConfirm: () => onUnlink(linkTo), children: _jsx(IconButton, { icon: Unlink2Icon, size: "icon-xs", label: `Unlink ${g.label}` }) })), !main && recipe && onRemoveRecipe && (_jsx(LumenConfirm, { title: `Remove ${g.label}?`, description: `Its columns leave this widget when you save. The recipe stays in ${assistantName}.`, action: "Remove", onConfirm: () => onRemoveRecipe(recipe), children: _jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Remove ${g.label}` }) }))] }), _jsxs(CollapsibleContent, { className: "ml-[0.9rem] flex flex-col gap-px border-l-2 pl-2", style: { borderColor: guide(color) }, children: [g.note && !q && (_jsx("p", { className: "px-1 py-1 text-xs text-muted-foreground", children: g.note })), linkTo && linkPairs && !q && (_jsx(LinkPairs, { label: g.label, on: linkPairs.onOf(linkTo), cardFields: linkPairs.cardFields, fields: linkPairs.fieldsOf(linkTo), onChange: (on) => linkPairs.set(linkTo, on) })), unlinked &&
812
+ linkPairs
813
+ ?.fieldsOf(linkTo)
814
+ .filter(match)
815
+ .map((f) => (_jsxs("div", { className: "flex min-h-7 items-center gap-2 px-1 text-sm text-muted-foreground opacity-60", children: [_jsx(WhyOff, { note: "Link it first", children: _jsx(Checkbox, { checked: false, disabled: true, "aria-label": `Use ${f.label}` }) }), _jsx("span", { className: "truncate", children: f.label })] }, f.key))), topics.map((t) => {
816
+ const inTopic = fields.filter((f) => (f.topic ?? '') === t);
817
+ if (!t)
818
+ return inTopic.map(leaf);
819
+ const src = g.sources?.[t];
820
+ const id = `t:${g.id}:${t}`;
821
+ const topicOpen = isOpen(id, all.filter((f) => f.topic === t));
822
+ return (_jsxs(Collapsible, { open: topicOpen, onOpenChange: () => flip(id, topicOpen), children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "h-7 w-full justify-start gap-1.5 px-1 text-[0.8125rem] font-semibold", children: [_jsx(ChevronRightIcon, { className: cn('text-muted-foreground transition-transform', topicOpen && 'rotate-90') }), _jsx("span", { className: "truncate", children: t }), _jsxs("span", { className: "num text-xs font-normal text-muted-foreground", children: [inTopic.filter((f) => picked(f.key)).length, "/", inTopic.length] }), src && (_jsxs("span", { className: "ml-auto flex min-w-0 items-center gap-1.5 text-xs font-normal text-muted-foreground", children: [_jsx("span", { className: "size-1.5 shrink-0 rounded-full", style: { background: src.color } }), _jsx("span", { className: "font-medium", style: { color: src.color }, children: src.name }), src.detail && (_jsxs("span", { className: "truncate max-md:hidden", children: ["\u00B7 ", src.detail] }))] }))] }) }), _jsx(CollapsibleContent, { className: "ml-[0.7rem] flex flex-col gap-px border-l-2 pl-2", style: { borderColor: guide(src?.color ?? PLAIN) }, children: inTopic.map(leaf) })] }, t));
823
+ }), formulasGroup && onAddFormula && !q && (_jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 justify-start gap-2 px-1 font-normal text-muted-foreground", onClick: onAddFormula, children: [_jsx(PlusIcon, {}), "Add formula"] }))] })] }, g.id));
824
+ })] }));
684
825
  }
685
826
  /** A key's name as a person reads it: `symbol` is Symbol. */
686
827
  const fieldName = (k) => (k[0]?.toUpperCase() ?? '') + k.slice(1).replace(/_/g, ' ');
@@ -0,0 +1,4 @@
1
+ import type { LumenOption } from './lumen-selector.js';
2
+ /** The page's own rows, as a data option's value. */
3
+ export declare const PAGE_SOURCE = "_page";
4
+ export declare function useWidgetDataOptions(enabled: boolean, pageLabel: string): LumenOption[];
@@ -0,0 +1,32 @@
1
+ // The data a widget can take as its main data: this page's rows, the app's live domains with a
2
+ // reader, and the person's recipes. Add widget's first step and Edit widget ▸ Data both pick from it.
3
+ import { CATALOG, FRAME_LINK, catalogKeys, readableDomain, } from '@columenlabs/lumen-ui-types/catalog';
4
+ import { DatabaseIcon, SparklesIcon, TableIcon } from 'lucide-react';
5
+ import { asOf, useFrameChoices } from './joins.js';
6
+ import { useApp } from './shell.js';
7
+ /** The page's own rows, as a data option's value. */
8
+ export const PAGE_SOURCE = '_page';
9
+ export function useWidgetDataOptions(enabled, pageLabel) {
10
+ const app = useApp();
11
+ const choices = useFrameChoices(enabled);
12
+ return [
13
+ { value: PAGE_SOURCE, label: pageLabel, group: 'This page', icon: TableIcon },
14
+ ...Object.entries(CATALOG)
15
+ .filter(([id, d]) => d.desk === app?.id && d.status === 'live' && readableDomain(id))
16
+ .map(([id, d]) => ({
17
+ value: id,
18
+ label: d.label,
19
+ group: `${app?.label ?? 'This app'} data`,
20
+ detail: catalogKeys(id).join(', ') || undefined,
21
+ icon: DatabaseIcon,
22
+ keywords: [d.about],
23
+ })),
24
+ ...(choices.data ?? []).map((f) => ({
25
+ value: `${FRAME_LINK}${f.id}`,
26
+ label: f.name,
27
+ group: 'Recipes',
28
+ detail: asOf(f.at),
29
+ icon: SparklesIcon,
30
+ })),
31
+ ];
32
+ }
@@ -75,6 +75,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
75
75
  op?: "between" | "eq" | "gt" | "lt" | undefined;
76
76
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
77
77
  }[];
78
+ source?: string | undefined;
79
+ connections?: {
80
+ to: string;
81
+ on: {
82
+ from: string;
83
+ to: string;
84
+ }[];
85
+ }[] | undefined;
78
86
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
79
87
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
80
88
  kind: "table";
@@ -130,6 +138,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
130
138
  op?: "between" | "eq" | "gt" | "lt" | undefined;
131
139
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
132
140
  }[];
141
+ source?: string | undefined;
142
+ connections?: {
143
+ to: string;
144
+ on: {
145
+ from: string;
146
+ to: string;
147
+ }[];
148
+ }[] | undefined;
133
149
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
134
150
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
135
151
  kind: "table";
@@ -157,6 +173,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
157
173
  op?: "between" | "eq" | "gt" | "lt" | undefined;
158
174
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
159
175
  }[];
176
+ source?: string | undefined;
177
+ connections?: {
178
+ to: string;
179
+ on: {
180
+ from: string;
181
+ to: string;
182
+ }[];
183
+ }[] | undefined;
160
184
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
161
185
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
162
186
  kind: "metric";
@@ -225,6 +249,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
225
249
  op?: "between" | "eq" | "gt" | "lt" | undefined;
226
250
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
227
251
  }[];
252
+ source?: string | undefined;
253
+ connections?: {
254
+ to: string;
255
+ on: {
256
+ from: string;
257
+ to: string;
258
+ }[];
259
+ }[] | undefined;
228
260
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
229
261
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
230
262
  kind: "metric";
@@ -287,6 +319,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
287
319
  op?: "between" | "eq" | "gt" | "lt" | undefined;
288
320
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
289
321
  }[];
322
+ source?: string | undefined;
323
+ connections?: {
324
+ to: string;
325
+ on: {
326
+ from: string;
327
+ to: string;
328
+ }[];
329
+ }[] | undefined;
290
330
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
291
331
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
292
332
  w: number;
@@ -316,6 +356,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
316
356
  op?: "between" | "eq" | "gt" | "lt" | undefined;
317
357
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
318
358
  }[];
359
+ source?: string | undefined;
360
+ connections?: {
361
+ to: string;
362
+ on: {
363
+ from: string;
364
+ to: string;
365
+ }[];
366
+ }[] | undefined;
319
367
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
320
368
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
321
369
  kind: "allocation";
@@ -343,6 +391,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
343
391
  op?: "between" | "eq" | "gt" | "lt" | undefined;
344
392
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
345
393
  }[];
394
+ source?: string | undefined;
395
+ connections?: {
396
+ to: string;
397
+ on: {
398
+ from: string;
399
+ to: string;
400
+ }[];
401
+ }[] | undefined;
346
402
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
347
403
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
348
404
  kind: "allocation";
@@ -370,6 +426,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
370
426
  op?: "between" | "eq" | "gt" | "lt" | undefined;
371
427
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
372
428
  }[];
429
+ source?: string | undefined;
430
+ connections?: {
431
+ to: string;
432
+ on: {
433
+ from: string;
434
+ to: string;
435
+ }[];
436
+ }[] | undefined;
373
437
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
374
438
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
375
439
  kind: "allocation";
@@ -400,6 +464,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
400
464
  op?: "between" | "eq" | "gt" | "lt" | undefined;
401
465
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
402
466
  }[];
467
+ source?: string | undefined;
468
+ connections?: {
469
+ to: string;
470
+ on: {
471
+ from: string;
472
+ to: string;
473
+ }[];
474
+ }[] | undefined;
403
475
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
404
476
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
405
477
  kind: "chart";
@@ -424,6 +496,14 @@ export declare const readsRows: (card: ViewWidget) => card is {
424
496
  op?: "between" | "eq" | "gt" | "lt" | undefined;
425
497
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
426
498
  }[];
499
+ source?: string | undefined;
500
+ connections?: {
501
+ to: string;
502
+ on: {
503
+ from: string;
504
+ to: string;
505
+ }[];
506
+ }[] | undefined;
427
507
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
428
508
  controls?: Partial<Record<"ask" | "columns" | "download" | "filter" | "footer" | "group" | "header" | "legend" | "pager" | "refresh" | "window", boolean>> | undefined;
429
509
  w: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  "@codemirror/lang-javascript": "6.2.5",
42
42
  "@codemirror/lang-json": "6.0.2",
43
43
  "@codemirror/lang-sql": "6.10.0",
44
- "@columenlabs/lumen-ui-types": "0.2.0",
44
+ "@columenlabs/lumen-ui-types": "0.2.2",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",