@columenlabs/lumen-ui 0.0.0-stage → 0.2.1
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.
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +4 -2
- package/dist/add-column.d.ts +23 -0
- package/dist/add-column.js +62 -0
- package/dist/api-fetch.d.ts +9 -0
- package/dist/api-fetch.js +60 -0
- package/dist/app-switcher.d.ts +21 -0
- package/dist/app-switcher.js +12 -0
- package/dist/app-views.d.ts +18 -0
- package/dist/app-views.js +41 -0
- package/dist/assistant-context.d.ts +72 -0
- package/dist/assistant-context.js +30 -0
- package/dist/assistant-panel.d.ts +58 -0
- package/dist/assistant-panel.js +244 -0
- package/dist/assistant-screen.d.ts +124 -0
- package/dist/assistant-screen.js +252 -0
- package/dist/card-switch.d.ts +16 -0
- package/dist/card-switch.js +16 -0
- package/dist/color-dot.d.ts +14 -0
- package/dist/color-dot.js +19 -0
- package/dist/confirm.d.ts +18 -0
- package/dist/confirm.js +5 -0
- package/dist/connections.d.ts +45 -0
- package/dist/connections.js +275 -0
- package/dist/coverage-calendar-map.d.ts +27 -0
- package/dist/coverage-calendar-map.js +84 -0
- package/dist/coverage-cells.d.ts +55 -0
- package/dist/coverage-cells.js +85 -0
- package/dist/coverage-map.d.ts +112 -0
- package/dist/coverage-map.js +428 -0
- package/dist/details-table.d.ts +20 -0
- package/dist/details-table.js +13 -0
- package/dist/drawer-section.d.ts +27 -0
- package/dist/drawer-section.js +23 -0
- package/dist/floating-head.d.ts +2 -0
- package/dist/floating-head.js +57 -0
- package/dist/fold.d.ts +16 -0
- package/dist/fold.js +70 -0
- package/dist/form-issues.d.ts +32 -0
- package/dist/form-issues.js +60 -0
- package/dist/formula-input.d.ts +17 -0
- package/dist/formula-input.js +104 -0
- package/dist/frame-script.d.ts +7 -0
- package/dist/frame-script.js +103 -0
- package/dist/freshness.d.ts +24 -0
- package/dist/freshness.js +99 -0
- package/dist/group-button.d.ts +33 -0
- package/dist/group-button.js +67 -0
- package/dist/growth-chart.d.ts +77 -0
- package/dist/growth-chart.js +195 -0
- package/dist/hooks/use-keyboard-fit.d.ts +15 -0
- package/dist/hooks/use-keyboard-fit.js +29 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/icon-button.d.ts +21 -0
- package/dist/icon-button.js +22 -0
- package/dist/icon-picker.d.ts +14 -0
- package/dist/icon-picker.js +36 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +90 -0
- package/dist/integrations.d.ts +57 -0
- package/dist/integrations.js +73 -0
- package/dist/joins.d.ts +128 -0
- package/dist/joins.js +767 -0
- package/dist/kit-config.d.ts +13 -0
- package/dist/kit-config.js +15 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lumen-boundary.d.ts +7 -0
- package/dist/lumen-boundary.js +10 -0
- package/dist/lumen-code-editor-impl.d.ts +2 -0
- package/dist/lumen-code-editor-impl.js +16 -0
- package/dist/lumen-code-editor.d.ts +11 -0
- package/dist/lumen-code-editor.js +11 -0
- package/dist/lumen-code.d.ts +22 -0
- package/dist/lumen-code.js +75 -0
- package/dist/lumen-drawer.d.ts +30 -0
- package/dist/lumen-drawer.js +27 -0
- package/dist/lumen-echart.d.ts +58 -0
- package/dist/lumen-echart.js +129 -0
- package/dist/lumen-filter.d.ts +75 -0
- package/dist/lumen-filter.js +546 -0
- package/dist/lumen-finder.d.ts +62 -0
- package/dist/lumen-finder.js +275 -0
- package/dist/lumen-form.d.ts +34 -0
- package/dist/lumen-form.js +81 -0
- package/dist/lumen-geo-map.d.ts +9 -0
- package/dist/lumen-geo-map.js +135 -0
- package/dist/lumen-grid.d.ts +49 -0
- package/dist/lumen-grid.js +203 -0
- package/dist/lumen-layout.d.ts +150 -0
- package/dist/lumen-layout.js +661 -0
- package/dist/lumen-links.d.ts +96 -0
- package/dist/lumen-links.js +213 -0
- package/dist/lumen-page-state.d.ts +20 -0
- package/dist/lumen-page-state.js +15 -0
- package/dist/lumen-selector.d.ts +139 -0
- package/dist/lumen-selector.js +308 -0
- package/dist/lumen-source.d.ts +137 -0
- package/dist/lumen-source.js +327 -0
- package/dist/lumen-tab-drawer.d.ts +27 -0
- package/dist/lumen-tab-drawer.js +26 -0
- package/dist/lumen-tabs.d.ts +21 -0
- package/dist/lumen-tabs.js +6 -0
- package/dist/lumen-toast.d.ts +11 -0
- package/dist/lumen-toast.js +16 -0
- package/dist/lumen-trend.d.ts +18 -0
- package/dist/lumen-trend.js +157 -0
- package/dist/lumen-view-card.d.ts +35 -0
- package/dist/lumen-view-card.js +50 -0
- package/dist/lumen-view.d.ts +172 -0
- package/dist/lumen-view.js +645 -0
- package/dist/lumen-widget-charts.d.ts +57 -0
- package/dist/lumen-widget-charts.js +69 -0
- package/dist/lumen-widget.d.ts +720 -0
- package/dist/lumen-widget.js +609 -0
- package/dist/market/compare-chart.d.ts +34 -0
- package/dist/market/compare-chart.js +292 -0
- package/dist/market/indicator-picker.d.ts +29 -0
- package/dist/market/indicator-picker.js +125 -0
- package/dist/market/indicators.d.ts +63 -0
- package/dist/market/indicators.js +314 -0
- package/dist/market/live.d.ts +120 -0
- package/dist/market/live.js +444 -0
- package/dist/market/lumen-chart.d.ts +88 -0
- package/dist/market/lumen-chart.js +645 -0
- package/dist/market/market-widget.d.ts +45 -0
- package/dist/market/market-widget.js +346 -0
- package/dist/market/option-chain.d.ts +19 -0
- package/dist/market/option-chain.js +53 -0
- package/dist/market/price-chart.d.ts +128 -0
- package/dist/market/price-chart.js +619 -0
- package/dist/mcp-settings.d.ts +20 -0
- package/dist/mcp-settings.js +65 -0
- package/dist/nav.d.ts +77 -0
- package/dist/nav.js +73 -0
- package/dist/note-line.d.ts +13 -0
- package/dist/note-line.js +5 -0
- package/dist/notifications.d.ts +9 -0
- package/dist/notifications.js +166 -0
- package/dist/origin-legend.d.ts +14 -0
- package/dist/origin-legend.js +38 -0
- package/dist/overview.d.ts +84 -0
- package/dist/overview.js +105 -0
- package/dist/page-inputs.d.ts +14 -0
- package/dist/page-inputs.js +16 -0
- package/dist/page.d.ts +32 -0
- package/dist/page.js +45 -0
- package/dist/pager.d.ts +10 -0
- package/dist/pager.js +14 -0
- package/dist/prefs.d.ts +71 -0
- package/dist/prefs.js +126 -0
- package/dist/query-client.d.ts +7 -0
- package/dist/query-client.js +19 -0
- package/dist/refresh.d.ts +4 -0
- package/dist/refresh.js +19 -0
- package/dist/rename-field.d.ts +11 -0
- package/dist/rename-field.js +26 -0
- package/dist/reset-button.d.ts +9 -0
- package/dist/reset-button.js +10 -0
- package/dist/route-error.d.ts +42 -0
- package/dist/route-error.js +229 -0
- package/dist/schedules.d.ts +88 -0
- package/dist/schedules.js +221 -0
- package/dist/settings.d.ts +51 -0
- package/dist/settings.js +181 -0
- package/dist/shell.d.ts +99 -0
- package/dist/shell.js +137 -0
- package/dist/source-health.d.ts +12 -0
- package/dist/source-health.js +58 -0
- package/dist/sourced-card.d.ts +10 -0
- package/dist/sourced-card.js +69 -0
- package/dist/spotlight.d.ts +23 -0
- package/dist/spotlight.js +138 -0
- package/dist/status-health.d.ts +16 -0
- package/dist/status-health.js +41 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.js +12 -0
- package/dist/summary-cards.d.ts +56 -0
- package/dist/summary-cards.js +270 -0
- package/dist/symbol-sheet-context.d.ts +4 -0
- package/dist/symbol-sheet-context.js +5 -0
- package/dist/symbol-sheet.d.ts +9 -0
- package/dist/symbol-sheet.js +24 -0
- package/dist/tasks.d.ts +29 -0
- package/dist/tasks.js +278 -0
- package/dist/theme.css +606 -0
- package/dist/tile.d.ts +37 -0
- package/dist/tile.js +23 -0
- package/dist/ui/alert-dialog.d.ts +18 -0
- package/dist/ui/alert-dialog.js +41 -0
- package/dist/ui/alert.d.ts +10 -0
- package/dist/ui/alert.js +27 -0
- package/dist/ui/avatar.d.ts +11 -0
- package/dist/ui/avatar.js +22 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/badge.js +27 -0
- package/dist/ui/breadcrumb.d.ts +11 -0
- package/dist/ui/breadcrumb.js +27 -0
- package/dist/ui/button-group.d.ts +11 -0
- package/dist/ui/button-group.js +27 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +38 -0
- package/dist/ui/card.d.ts +11 -0
- package/dist/ui/card.js +24 -0
- package/dist/ui/chart.d.ts +44 -0
- package/dist/ui/chart.js +117 -0
- package/dist/ui/checkbox.d.ts +4 -0
- package/dist/ui/checkbox.js +8 -0
- package/dist/ui/collapsible.d.ts +5 -0
- package/dist/ui/collapsible.js +12 -0
- package/dist/ui/combobox.d.ts +24 -0
- package/dist/ui/combobox.js +60 -0
- package/dist/ui/command.d.ts +18 -0
- package/dist/ui/command.js +34 -0
- package/dist/ui/dialog.d.ts +23 -0
- package/dist/ui/dialog.js +50 -0
- package/dist/ui/dropdown-menu.d.ts +29 -0
- package/dist/ui/dropdown-menu.js +50 -0
- package/dist/ui/emoji-picker.d.ts +6 -0
- package/dist/ui/emoji-picker.js +28 -0
- package/dist/ui/empty.d.ts +11 -0
- package/dist/ui/empty.js +33 -0
- package/dist/ui/field.d.ts +24 -0
- package/dist/ui/field.js +65 -0
- package/dist/ui/hover-card.d.ts +6 -0
- package/dist/ui/hover-card.js +13 -0
- package/dist/ui/input-group.d.ts +16 -0
- package/dist/ui/input-group.js +56 -0
- package/dist/ui/input.d.ts +3 -0
- package/dist/ui/input.js +6 -0
- package/dist/ui/item.d.ts +23 -0
- package/dist/ui/item.js +67 -0
- package/dist/ui/kbd.d.ts +3 -0
- package/dist/ui/kbd.js +9 -0
- package/dist/ui/label.d.ts +4 -0
- package/dist/ui/label.js +7 -0
- package/dist/ui/popover.d.ts +10 -0
- package/dist/ui/popover.js +25 -0
- package/dist/ui/progress.d.ts +4 -0
- package/dist/ui/progress.js +9 -0
- package/dist/ui/resizable.d.ts +7 -0
- package/dist/ui/resizable.js +13 -0
- package/dist/ui/scroll-area.d.ts +5 -0
- package/dist/ui/scroll-area.js +10 -0
- package/dist/ui/select.d.ts +15 -0
- package/dist/ui/select.js +36 -0
- package/dist/ui/separator.d.ts +4 -0
- package/dist/ui/separator.js +7 -0
- package/dist/ui/sheet.d.ts +14 -0
- package/dist/ui/sheet.js +54 -0
- package/dist/ui/sidebar.d.ts +69 -0
- package/dist/ui/sidebar.js +206 -0
- package/dist/ui/skeleton.d.ts +2 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/slider.d.ts +4 -0
- package/dist/ui/slider.js +9 -0
- package/dist/ui/sonner.d.ts +3 -0
- package/dist/ui/sonner.js +24 -0
- package/dist/ui/spinner.d.ts +2 -0
- package/dist/ui/spinner.js +7 -0
- package/dist/ui/switch.d.ts +6 -0
- package/dist/ui/switch.js +7 -0
- package/dist/ui/table.d.ts +10 -0
- package/dist/ui/table.js +31 -0
- package/dist/ui/tabs.d.ts +7 -0
- package/dist/ui/tabs.js +22 -0
- package/dist/ui/textarea.d.ts +3 -0
- package/dist/ui/textarea.js +6 -0
- package/dist/ui/toggle-group.d.ts +10 -0
- package/dist/ui/toggle-group.js +22 -0
- package/dist/ui/toggle.d.ts +9 -0
- package/dist/ui/toggle.js +25 -0
- package/dist/ui/tooltip.d.ts +7 -0
- package/dist/ui/tooltip.js +16 -0
- package/dist/view-edit-cell.d.ts +49 -0
- package/dist/view-edit-cell.js +198 -0
- package/dist/view-editor.d.ts +52 -0
- package/dist/view-editor.js +1081 -0
- package/dist/view-formulas.d.ts +61 -0
- package/dist/view-formulas.js +247 -0
- package/dist/view-icons.d.ts +20 -0
- package/dist/view-icons.js +70 -0
- package/dist/view-model.d.ts +390 -0
- package/dist/view-model.js +316 -0
- package/dist/whats-new.d.ts +3 -0
- package/dist/whats-new.js +53 -0
- package/dist/widget-controls.d.ts +9 -0
- package/dist/widget-controls.js +26 -0
- package/dist/widget-data.d.ts +4 -0
- package/dist/widget-data.js +32 -0
- package/dist/widget-settings.d.ts +516 -0
- package/dist/widget-settings.js +290 -0
- package/dist/widget-thumbs.d.ts +3 -0
- package/dist/widget-thumbs.js +29 -0
- package/dist/workspace-keys.d.ts +12 -0
- package/dist/workspace-keys.js +13 -0
- package/dist/workspaces.d.ts +57 -0
- package/dist/workspaces.js +473 -0
- package/package.json +82 -4
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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, customViewConfigSchema, inferConnection, isOwnTile, isRowCard, isTableCard, mainTableOf, sourceConfigOf, tableConfigOf, tableLayoutOf, viewAggs, viewCardSchema, viewPageSizes, viewPaginationSchema, widgetStyleId, } from '@columenlabs/lumen-ui-types/views';
|
|
4
|
+
// The Edit view dialog (mock 2b): two panes. Left, a tree of every field the
|
|
5
|
+
// view's rows reach, grouped by where it comes from (Holding, Symbol by topic, Account, Formulas),
|
|
6
|
+
// ticked to show. Right, what the view does with them: column order (drag), shape, group, filter,
|
|
7
|
+
// sort, header tiles, then the view id, Duplicate and Delete. Name, icon, Cancel and Save sit in
|
|
8
|
+
// the header. On a phone the panes are two tabs, Fields and Layout. Everything here is the view's
|
|
9
|
+
// config, which agents write the same way through MCP custom_view_write.
|
|
10
|
+
import { DndContext, KeyboardSensor, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core';
|
|
11
|
+
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
|
|
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, ReplaceIcon, RotateCcwIcon, SearchIcon, ShapesIcon, ShrinkIcon, SigmaIcon, SlidersHorizontalIcon, SmileIcon, SparklesIcon, StarIcon, TableIcon, ToggleRightIcon, Trash2Icon, Unlink2Icon, XIcon, } from 'lucide-react';
|
|
14
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import { AddFormulaDialog } from './add-column.js';
|
|
16
|
+
import { useAssistant, useAssistantName } from './assistant-context.js';
|
|
17
|
+
import { LumenConfirm } from './confirm.js';
|
|
18
|
+
import { connectCandidates, connectionFields, connectionOn, linkGroupId, } from './connections.js';
|
|
19
|
+
import { DrawerSection } from './drawer-section.js';
|
|
20
|
+
import { SaveButton } from './form-issues.js';
|
|
21
|
+
import { useIsMobile } from './hooks/use-mobile.js';
|
|
22
|
+
import { IconButton } from './icon-button.js';
|
|
23
|
+
import { IconPicker } from './icon-picker.js';
|
|
24
|
+
import { asOf, frameGroupId, rowsDomain, useDomainReads, useDraftFrames, useFrameChoices, } from './joins.js';
|
|
25
|
+
import { cn } from './lib/utils.js';
|
|
26
|
+
import { LumenTokenizedFilter, filterByTokens, tokenText, useLumenFilter, } from './lumen-filter.js';
|
|
27
|
+
import { LinkPicker } from './lumen-links.js';
|
|
28
|
+
import { LumenSelector } from './lumen-selector.js';
|
|
29
|
+
import { lumenToast } from './lumen-toast.js';
|
|
30
|
+
import { CARD_COLUMNS, CARD_TITLE_MAX, cardFilterFields, cardTitle, draftOf, useOffered, } from './lumen-widget.js';
|
|
31
|
+
import { useApp } from './shell.js';
|
|
32
|
+
import { summaryCardIssues } from './summary-cards.js';
|
|
33
|
+
import { Badge } from './ui/badge.js';
|
|
34
|
+
import { Button } from './ui/button.js';
|
|
35
|
+
import { Checkbox } from './ui/checkbox.js';
|
|
36
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
|
|
37
|
+
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from './ui/dialog.js';
|
|
38
|
+
import { Input } from './ui/input.js';
|
|
39
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
|
|
40
|
+
import { Label } from './ui/label.js';
|
|
41
|
+
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
|
|
42
|
+
import { Separator } from './ui/separator.js';
|
|
43
|
+
import { Switch } from './ui/switch.js';
|
|
44
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs.js';
|
|
45
|
+
import { Textarea } from './ui/textarea.js';
|
|
46
|
+
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
|
|
47
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
|
|
48
|
+
import { AGG_LABELS, FORMAT_LABELS, FormatChip, FormulaForm, MAX_FORMULAS, fieldOptions, formatOptions, formulaIssueTarget, formulaIssues, formulaOptions, } from './view-formulas.js';
|
|
49
|
+
import { VIEW_ICONS, lucideIconNames, shapeIcon, viewIcon } from './view-icons.js';
|
|
50
|
+
import { FORMULAS_GROUP, canFilter, chipsOf, formulaBlanks, formulasOf, groupOf, hiddenOf, subgroupLabel, subgroupOf, subgroupsOf, summaryCash, takesFormulas, tilesOf, totalAggOf, withViewFields, } from './view-model.js';
|
|
51
|
+
import { WidgetControlsEditor } from './widget-controls.js';
|
|
52
|
+
import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
|
|
53
|
+
import { EditorRow as Row, WidgetFields, WidgetSettings, hasWidgetSettings, readsRows, slotKeys, widgetSlots, } from './widget-settings.js';
|
|
54
|
+
import { widgetThumb } from './widget-thumbs.js';
|
|
55
|
+
/** The field's own group and Total rows: a formula over the rolled-up inputs, or a share. */
|
|
56
|
+
const OWN_TOTAL = '_own';
|
|
57
|
+
/** The order: None, then the common roll-ups. */
|
|
58
|
+
const TOTAL_AGGS = ['sum', 'mean', 'min', 'max', 'count', 'distinct', 'last', 'first'];
|
|
59
|
+
const SHAPES = [
|
|
60
|
+
{ value: 'table', label: 'Table' },
|
|
61
|
+
{ value: 'group', label: 'Group' },
|
|
62
|
+
{ value: 'pivot', label: 'Pivot' },
|
|
63
|
+
];
|
|
64
|
+
const NONE = '_none';
|
|
65
|
+
/** The main data picker's value for the page's own rows. */
|
|
66
|
+
const NO_FRAMES = [];
|
|
67
|
+
/** A recipe's (a linked the assistant frame's) group id starts with this (`recipe:fr_…`). */
|
|
68
|
+
const FRAME_PREFIX = frameGroupId('');
|
|
69
|
+
/** A linked domain's group id starts with this (`link:holdings`). */
|
|
70
|
+
const LINK_PREFIX = linkGroupId('');
|
|
71
|
+
const CHIP_TEXT = 'text';
|
|
72
|
+
/** The colour of fields with no group colour (formulas, a domain without groups). */
|
|
73
|
+
const PLAIN = 'var(--color-muted-foreground)';
|
|
74
|
+
/** What a type switch keeps of a widget: its place, size, colour and controls. */
|
|
75
|
+
const KEPT = ['id', 'w', 'h', 'x', 'y', 'phone', 'link', 'controls'];
|
|
76
|
+
/** `list` with `key` on or off; an empty list stands for `all`, kept in `order`. */
|
|
77
|
+
const toggleIn = (list, all, key, on, order = all) => {
|
|
78
|
+
const base = list.length ? list : all;
|
|
79
|
+
const next = on ? [...base, key] : base.filter((k) => k !== key);
|
|
80
|
+
return order.filter((k) => next.includes(k));
|
|
81
|
+
};
|
|
82
|
+
/** A Summary card with a renamed formula key carried through its main number and lines. */
|
|
83
|
+
const renameTile = (t, rename) => isOwnTile(t)
|
|
84
|
+
? {
|
|
85
|
+
...t,
|
|
86
|
+
field: rename(t.field),
|
|
87
|
+
lines: t.lines.map((l) => (l.kind === 'text' ? l : { ...l, field: rename(l.field) })),
|
|
88
|
+
}
|
|
89
|
+
: t;
|
|
90
|
+
const sensorsOptions = { activationConstraint: { distance: 4 } };
|
|
91
|
+
function useDrag() {
|
|
92
|
+
return useSensors(useSensor(PointerSensor, sensorsOptions), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
93
|
+
}
|
|
94
|
+
/** Moves `active` to `over` in `keys` after a drag; the same list when nothing moved. */
|
|
95
|
+
const moved = (keys, e) => {
|
|
96
|
+
const from = keys.indexOf(String(e.active.id));
|
|
97
|
+
const to = e.over ? keys.indexOf(String(e.over.id)) : -1;
|
|
98
|
+
return from < 0 || to < 0 || from === to ? keys : arrayMove(keys, from, to);
|
|
99
|
+
};
|
|
100
|
+
/** A colour softened for a tree's guide line. */
|
|
101
|
+
const guide = (color) => `color-mix(in oklab, ${color} 45%, transparent)`;
|
|
102
|
+
/** The tree's groups: the domain's own, else one; then the view's formulas. */
|
|
103
|
+
function groupsOf(domain) {
|
|
104
|
+
const own = domain.groups?.length ? domain.groups : [{ id: '', label: 'Fields' }];
|
|
105
|
+
return takesFormulas(domain)
|
|
106
|
+
? [...own, { id: FORMULAS_GROUP, label: 'Formulas', color: PLAIN }]
|
|
107
|
+
: own;
|
|
108
|
+
}
|
|
109
|
+
const OPEN_AT_START = ['widget', 'fields'];
|
|
110
|
+
/** The table layout a widget's editor starts from: its own, else the view's. */
|
|
111
|
+
const tableDraftOf = (config, card) => tableConfigOf(config, isTableCard(card)
|
|
112
|
+
? { ...card, table: card.table ?? tableLayoutOf(config) }
|
|
113
|
+
: viewCardSchema.parse({
|
|
114
|
+
...Object.fromEntries(KEPT.filter((k) => card[k] !== undefined).map((k) => [k, card[k]])),
|
|
115
|
+
kind: 'table',
|
|
116
|
+
style: 'table',
|
|
117
|
+
title: card.title,
|
|
118
|
+
filter: [],
|
|
119
|
+
table: tableLayoutOf(config),
|
|
120
|
+
}));
|
|
121
|
+
/**
|
|
122
|
+
* Edit widget (one editor for every widget). The same two panes for every
|
|
123
|
+
* widget: on the left Widget (type, name, width, link colour), Fields (a table's columns, any other
|
|
124
|
+
* widget's slots), Settings (the type's own options), Rows, Formulas, Controls; on the right the
|
|
125
|
+
* field tree, which ticks a table's columns or fills a widget's slot. Picking another type swaps
|
|
126
|
+
* only Fields and Settings; a type tried and left comes back as it was. The view's own table stays
|
|
127
|
+
* a table.
|
|
128
|
+
*/
|
|
129
|
+
export function ViewEditDialog({ open, onOpenChange, domain: pageDomain, config, rows: pageRows, current, onSave, card, onSaveCard, fields: pageFields = [], viewTiles, read, viewId, editor, onDuplicate, onDelete, }) {
|
|
130
|
+
const isMobile = useIsMobile();
|
|
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;
|
|
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 ?? []));
|
|
140
|
+
// Recipes: the assistant frames the draft attaches, their columns in the tree
|
|
141
|
+
// before Save.
|
|
142
|
+
const frameIds = draft.frames ?? NO_FRAMES;
|
|
143
|
+
const base = useDraftFrames(joined, frameIds);
|
|
144
|
+
const assistant = useAssistant();
|
|
145
|
+
const choices = useFrameChoices(open);
|
|
146
|
+
const app = useApp();
|
|
147
|
+
// The widget being edited when it is not a table of the rows; null while it is one.
|
|
148
|
+
const [widget, setWidget] = useState(() => card && !isTableCard(card) ? card : null);
|
|
149
|
+
// A widget's frame (place, width, colour, controls) and name, kept across type switches.
|
|
150
|
+
const [frame, setFrame] = useState(card ?? null);
|
|
151
|
+
const [title, setTitle] = useState(card?.title ?? '');
|
|
152
|
+
const [tokens, setTokens] = useState(card?.filter ?? []);
|
|
153
|
+
const [tab, setTab] = useState('view');
|
|
154
|
+
const [sections, setSections] = useState(new Set(OPEN_AT_START));
|
|
155
|
+
const [slotId, setSlotId] = useState(null);
|
|
156
|
+
// Each type's settings as last left here, so trying another and coming back restores them.
|
|
157
|
+
const tried = useRef(new Map());
|
|
158
|
+
// The formula open in the Formulas section.
|
|
159
|
+
const [editing, setEditing] = useState(null);
|
|
160
|
+
// The Add a formula column dialog, the same one the table header's + opens.
|
|
161
|
+
const [addingFormula, setAddingFormula] = useState(false);
|
|
162
|
+
// Each opening starts from the view as it is now (a column added from the
|
|
163
|
+
// header's + was missing). The parent opens the dialog through `open`, which never calls
|
|
164
|
+
// onOpenChange, so the reset runs when `open` turns true.
|
|
165
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
166
|
+
if (open !== wasOpen) {
|
|
167
|
+
setWasOpen(open);
|
|
168
|
+
if (open) {
|
|
169
|
+
setSource(card?.source ?? null);
|
|
170
|
+
setDraft(start());
|
|
171
|
+
setWidget(card && !isTableCard(card) ? card : null);
|
|
172
|
+
setFrame(card ?? null);
|
|
173
|
+
setTitle(card?.title ?? '');
|
|
174
|
+
setTokens(card?.filter ?? []);
|
|
175
|
+
tried.current.clear();
|
|
176
|
+
setEditing(null);
|
|
177
|
+
setTab('view');
|
|
178
|
+
setSlotId(null);
|
|
179
|
+
setSections(new Set(OPEN_AT_START));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Linked domains (design, part 3): each link is one more group in the tree,
|
|
183
|
+
// and a domain this widget could link to shows below them as a dashed row with Link.
|
|
184
|
+
const unlinked = useMemo(() => {
|
|
185
|
+
const own = base.fields.filter((f) => !f.group?.startsWith(LINK_PREFIX));
|
|
186
|
+
const groups = (base.groups ?? []).filter((g) => !g.id.startsWith(LINK_PREFIX));
|
|
187
|
+
return { ...base, fields: own, groups: groups.length ? groups : [{ id: '', label: 'Fields' }] };
|
|
188
|
+
}, [base]);
|
|
189
|
+
const connections = draft.connections ?? [];
|
|
190
|
+
// The draft's links read here as well, so one added before Save shows its fields: a pending one has nothing to read until it is linked, but its fields name the pairs.
|
|
191
|
+
const connIdKey = connections.map((c) => c.to).join(',');
|
|
192
|
+
const connIds = useMemo(() => (open && connIdKey ? connIdKey.split(',') : NO_FRAMES), [open, connIdKey]);
|
|
193
|
+
const draftRead = useDomainReads(connIds, connIds);
|
|
194
|
+
const allRead = useMemo(() => ({ ...(read ?? {}), ...draftRead }), [read, draftRead]);
|
|
195
|
+
const linked = useMemo(() => connectionFields(unlinked, connections, allRead, new Set(unlinked.fields.map((f) => f.key))), [unlinked, connections, allRead]);
|
|
196
|
+
const withLinks = {
|
|
197
|
+
...unlinked,
|
|
198
|
+
fields: [...unlinked.fields, ...linked.fields],
|
|
199
|
+
groups: [...unlinked.groups, ...linked.groups],
|
|
200
|
+
};
|
|
201
|
+
const offers = connectCandidates(base.id, draft).flatMap((to) => {
|
|
202
|
+
const other = allRead[to];
|
|
203
|
+
const on = other?.state === 'ok' ? connectionOn(unlinked, other.domain, undefined) : null;
|
|
204
|
+
return other && on ? [{ to, label: other.label, on }] : [];
|
|
205
|
+
});
|
|
206
|
+
// + Data's app group ("add any of the available data domains or recipes,
|
|
207
|
+
// but they don't get activated unless a link is defined"): every live catalog domain of this
|
|
208
|
+
// app; a shared key links it at once, else it waits Not linked for a field on each side.
|
|
209
|
+
const cardKeys = Object.keys(base.keys ?? {});
|
|
210
|
+
const deskDomains = Object.entries(CATALOG)
|
|
211
|
+
.filter(([id, d]) => d.desk === app?.id &&
|
|
212
|
+
d.status === 'live' &&
|
|
213
|
+
id !== base.id &&
|
|
214
|
+
!connections.some((c) => c.to === id))
|
|
215
|
+
.map(([id, d]) => ({
|
|
216
|
+
id,
|
|
217
|
+
label: d.label,
|
|
218
|
+
about: d.about,
|
|
219
|
+
on: offers.find((o) => o.to === id)?.on ?? inferConnection(cardKeys, catalogKeys(id)) ?? [],
|
|
220
|
+
by: catalogKeys(id).filter((k) => cardKeys.includes(k)),
|
|
221
|
+
...(readableDomain(id) ? {} : { why: 'No function reads its rows yet' }),
|
|
222
|
+
}));
|
|
223
|
+
const link = (to, on) => setDraft({
|
|
224
|
+
...draft,
|
|
225
|
+
connections: [...connections, { to, on }],
|
|
226
|
+
// A table shows the domain's usual columns once linked; any other widget picks from the tree.
|
|
227
|
+
columns: widget || !on.length
|
|
228
|
+
? draft.columns
|
|
229
|
+
: [
|
|
230
|
+
...shownKeys,
|
|
231
|
+
...(linkColumns(to).length ? linkColumns(to) : [CONNECTED_COUNT]).map((k) => connectedKey(to, k)),
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
const unlink = (to) => {
|
|
235
|
+
const gone = new Set(withLinks.fields.filter((f) => f.group === linkGroupId(to)).map((f) => f.key));
|
|
236
|
+
setDraft({
|
|
237
|
+
...draft,
|
|
238
|
+
connections: connections.filter((c) => c.to !== to),
|
|
239
|
+
columns: draft.columns.filter((k) => !gone.has(k)),
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
// A link's field pairs, picked in its group of the tree.
|
|
243
|
+
const setPairs = (to, on) => setDraft({
|
|
244
|
+
...draft,
|
|
245
|
+
connections: connections.map((c) => (c.to === to ? { ...c, on } : c)),
|
|
246
|
+
});
|
|
247
|
+
// + Data's recipes: every the assistant frame of the person's. One keyed like this card's rows attaches by
|
|
248
|
+
// its key (`frames`); any other links by field pairs (`frame:<id>`).
|
|
249
|
+
const byKey = (f) => !!f.key && !!base.keys?.[f.key];
|
|
250
|
+
const recipes = (choices.data ?? []).filter((f) => !frameIds.includes(f.id) && !connections.some((c) => c.to === `${FRAME_LINK}${f.id}`));
|
|
251
|
+
const addRecipe = (f) => (byKey(f) ? addFrame(f) : link(`${FRAME_LINK}${f.id}`, []));
|
|
252
|
+
const addFrame = (f) => setDraft({
|
|
253
|
+
...draft,
|
|
254
|
+
frames: [...frameIds, f.id],
|
|
255
|
+
// A table shows the recipe's columns at once; any other widget picks from the tree.
|
|
256
|
+
columns: widget ? draft.columns : [...shownKeys, ...f.columns.map((c) => c.key)],
|
|
257
|
+
});
|
|
258
|
+
const removeFrame = (id) => {
|
|
259
|
+
const gone = new Set(withLinks.fields.filter((f) => f.group === frameGroupId(id)).map((f) => f.key));
|
|
260
|
+
setDraft({
|
|
261
|
+
...draft,
|
|
262
|
+
frames: frameIds.filter((x) => x !== id),
|
|
263
|
+
columns: draft.columns.filter((k) => !gone.has(k)),
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
// New recipe: the person says what they want, the assistant writes it as a frame linked to this card. The
|
|
267
|
+
// message waits in the composer for them to send (never sent on the tap), so
|
|
268
|
+
// the edits so far are saved first.
|
|
269
|
+
const cardRef = card ?? mainTableOf(cardsOf(config));
|
|
270
|
+
const askRecipe = assistant?.ready && base.keys
|
|
271
|
+
? (wish) => {
|
|
272
|
+
const by = Object.keys(base.keys ?? {}).join(' or ');
|
|
273
|
+
const title = name.trim() || styleLabel(styleId);
|
|
274
|
+
save();
|
|
275
|
+
assistant.draft(`Make a recipe for the "${title}" card: ${wish.trim()} Keep it as a frame keyed by ${by} and link it to this card.`, [
|
|
276
|
+
{
|
|
277
|
+
kind: 'card',
|
|
278
|
+
label: title,
|
|
279
|
+
id: cardRef?.id ?? viewId ?? base.id,
|
|
280
|
+
card: cardRef ? widgetStyleId(cardRef) : undefined,
|
|
281
|
+
where: typeof document === 'undefined' ? undefined : document.title || undefined,
|
|
282
|
+
},
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
: undefined;
|
|
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);
|
|
289
|
+
const formulas = formulasOf(draft);
|
|
290
|
+
// Each formula's blank rows on the page's rows now, and why.
|
|
291
|
+
const formulaKey = JSON.stringify(formulas);
|
|
292
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the formulas (by value) and rows decide it; domain is rebuilt each render.
|
|
293
|
+
const blanks = useMemo(() => formulaBlanks(domain, rows), [formulaKey, rows]);
|
|
294
|
+
const formulaProblems = formulaIssues(formulas, withLinks);
|
|
295
|
+
const allKeys = domain.fields.map((f) => f.key);
|
|
296
|
+
const shownKeys = draft.columns.length
|
|
297
|
+
? draft.columns.filter((k) => allKeys.includes(k))
|
|
298
|
+
: allKeys;
|
|
299
|
+
const hiddenKeys = hiddenOf(draft).filter((k) => allKeys.includes(k) && !shownKeys.includes(k));
|
|
300
|
+
const section = (id) => ({
|
|
301
|
+
open: sections.has(id),
|
|
302
|
+
onOpenChange: (o) => {
|
|
303
|
+
const next = new Set(sections);
|
|
304
|
+
if (o)
|
|
305
|
+
next.add(id);
|
|
306
|
+
else
|
|
307
|
+
next.delete(id);
|
|
308
|
+
setSections(next);
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
// The frame the Widget section edits: the widget's own, or the view's table card.
|
|
312
|
+
const tableCard = mainTableOf(cardsOf(draft));
|
|
313
|
+
const shown = widget ?? frame ?? tableCard ?? null;
|
|
314
|
+
const setFramePart = (p) => {
|
|
315
|
+
if (widget)
|
|
316
|
+
setWidget({ ...widget, ...p });
|
|
317
|
+
if (frame)
|
|
318
|
+
setFrame({ ...frame, ...p });
|
|
319
|
+
if (!card && tableCard)
|
|
320
|
+
setDraft({
|
|
321
|
+
...draft,
|
|
322
|
+
cards: cardsOf(draft).map((c) => (c === tableCard ? { ...c, ...p } : c)),
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
const offered = useOffered(domain);
|
|
326
|
+
const styleId = widget ? widgetStyleId(widget) : 'table:table';
|
|
327
|
+
const pickStyle = (id) => {
|
|
328
|
+
if (id === styleId)
|
|
329
|
+
return;
|
|
330
|
+
if (widget)
|
|
331
|
+
tried.current.set(styleId, widget);
|
|
332
|
+
if (id === 'table:table') {
|
|
333
|
+
setWidget(null);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const keep = (w) => {
|
|
337
|
+
const from = widget ?? frame;
|
|
338
|
+
const kept = from
|
|
339
|
+
? Object.fromEntries(KEPT.filter((k) => from[k] !== undefined).map((k) => [k, from[k]]))
|
|
340
|
+
: {};
|
|
341
|
+
return { ...w, ...kept };
|
|
342
|
+
};
|
|
343
|
+
const back = tried.current.get(id);
|
|
344
|
+
if (back)
|
|
345
|
+
return setWidget(keep(back));
|
|
346
|
+
const w = WIDGET_STYLES.find((x) => x.id === id);
|
|
347
|
+
if (!w)
|
|
348
|
+
return;
|
|
349
|
+
const fresh = draftOf(id, domain, []);
|
|
350
|
+
// Another style that keeps the same settings (a number and its progress bar, one trend and
|
|
351
|
+
// another) changes only its drawing; any other starts its fields afresh.
|
|
352
|
+
if (widget && Object.keys(fresh).every((k) => k in widget || k === 'target'))
|
|
353
|
+
return setWidget({ ...widget, kind: w.kind, style: w.style });
|
|
354
|
+
setWidget(keep(isRowCard(fresh) ? { ...fresh, tiles: viewTiles ?? [] } : fresh));
|
|
355
|
+
setSlotId(null);
|
|
356
|
+
};
|
|
357
|
+
const styleOptions = WIDGET_STYLES.filter((w) => offered(w.id)).map((w) => ({
|
|
358
|
+
value: w.id,
|
|
359
|
+
label: w.name,
|
|
360
|
+
group: VIEW_CARD_KIND_NAMES[w.kind],
|
|
361
|
+
description: w.note,
|
|
362
|
+
thumb: widgetThumb(w.id),
|
|
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]);
|
|
455
|
+
// A widget's rows: the view's, narrowed by its own filter, with or without cash.
|
|
456
|
+
const widgetRows = tokens.length ? filterByTokens(rows, filterFields, tokens, domain.text) : rows;
|
|
457
|
+
const cashRows = widget && isRowCard(widget) && domain.cash && !summaryCash(widget, tokens.length > 0)
|
|
458
|
+
? widgetRows.filter((r) => !domain.cash?.(r))
|
|
459
|
+
: widgetRows;
|
|
460
|
+
// The slot the tree fills: a table's columns, else the picked one of the widget's.
|
|
461
|
+
const slots = widget ? widgetSlots(widget, domain) : [];
|
|
462
|
+
const slot = slots.find((s) => s.id === slotId) ?? slots[0];
|
|
463
|
+
const setShown = (key, on) => setDraft({
|
|
464
|
+
...draft,
|
|
465
|
+
columns: on ? [...shownKeys, key] : shownKeys.filter((k) => k !== key),
|
|
466
|
+
hidden: hiddenOf(draft).filter((k) => k !== key),
|
|
467
|
+
});
|
|
468
|
+
const pick = widget
|
|
469
|
+
? slot && {
|
|
470
|
+
on: (k) => slot.keys.includes(k),
|
|
471
|
+
set: (k, on) => setWidget(slot.set(slotKeys(slot, k, on))),
|
|
472
|
+
why: (f) => (slot.accepts(f) ? null : slot.why),
|
|
473
|
+
}
|
|
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
|
+
};
|
|
490
|
+
const parsed = widget
|
|
491
|
+
? viewCardSchema.safeParse({
|
|
492
|
+
...widget,
|
|
493
|
+
...sourcePart,
|
|
494
|
+
title: title.trim() || null,
|
|
495
|
+
// A widget's own filter is its own: it never shares with the colour group.
|
|
496
|
+
filter: readsRows(widget) ? tokens.map(({ link: _, ...t }) => t) : [],
|
|
497
|
+
})
|
|
498
|
+
: null;
|
|
499
|
+
const cardProblems = widget && isRowCard(widget)
|
|
500
|
+
? summaryCardIssues(widget.tiles ?? [], domain.fields.map((f) => f.key), (domain.tiles ?? []).map((w) => w.key))
|
|
501
|
+
: [];
|
|
502
|
+
// What stops Save, each with the field it is about (form-issues.tsx).
|
|
503
|
+
const open_ = (id) => {
|
|
504
|
+
setTab('view');
|
|
505
|
+
setSections((s) => new Set(s).add(id));
|
|
506
|
+
};
|
|
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
|
+
: []),
|
|
518
|
+
...(!card && !draft.name.trim()
|
|
519
|
+
? [
|
|
520
|
+
{
|
|
521
|
+
where: 'Name',
|
|
522
|
+
message: 'Give it a name',
|
|
523
|
+
target: 'view-editor-name',
|
|
524
|
+
reveal: () => open_('widget'),
|
|
525
|
+
},
|
|
526
|
+
]
|
|
527
|
+
: []),
|
|
528
|
+
...formulas.flatMap((f, fi) => {
|
|
529
|
+
const message = formulaProblems[fi];
|
|
530
|
+
if (!message)
|
|
531
|
+
return [];
|
|
532
|
+
return [
|
|
533
|
+
{
|
|
534
|
+
where: `Formula ${f.label || f.key}`,
|
|
535
|
+
message,
|
|
536
|
+
target: formulaIssueTarget(f.key, message),
|
|
537
|
+
reveal: () => edit(f.key),
|
|
538
|
+
},
|
|
539
|
+
];
|
|
540
|
+
}),
|
|
541
|
+
...cardProblems.flatMap((message, j) => message
|
|
542
|
+
? [
|
|
543
|
+
{
|
|
544
|
+
where: `Summary card ${j + 1}`,
|
|
545
|
+
message,
|
|
546
|
+
target: `summary-card-${j}`,
|
|
547
|
+
reveal: () => open_('settings'),
|
|
548
|
+
},
|
|
549
|
+
]
|
|
550
|
+
: []),
|
|
551
|
+
...(parsed && !parsed.success
|
|
552
|
+
? parsed.error.issues.slice(0, 3).map((i) => ({
|
|
553
|
+
where: i.path.join('.') || 'Widget',
|
|
554
|
+
message: i.message,
|
|
555
|
+
target: 'view-editor-check',
|
|
556
|
+
}))
|
|
557
|
+
: []),
|
|
558
|
+
];
|
|
559
|
+
const edit = (key) => {
|
|
560
|
+
setEditing(key);
|
|
561
|
+
if (key) {
|
|
562
|
+
setSections(new Set(sections).add('formulas'));
|
|
563
|
+
setTab('view');
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
const addFormula = formulas.length < MAX_FORMULAS ? () => setAddingFormula(true) : undefined;
|
|
567
|
+
const removeFormula = (key) => {
|
|
568
|
+
if (editing === key)
|
|
569
|
+
setEditing(null);
|
|
570
|
+
setDraft({
|
|
571
|
+
...draft,
|
|
572
|
+
formulas: formulas.filter((f) => f.key !== key),
|
|
573
|
+
columns: draft.columns.filter((k) => k !== key),
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
const tableConfig = () => {
|
|
577
|
+
const keys = new Set(domain.fields.map((f) => f.key));
|
|
578
|
+
// A removed field leaves its chip and sort too.
|
|
579
|
+
return {
|
|
580
|
+
...draft,
|
|
581
|
+
name: draft.name.trim(),
|
|
582
|
+
columns: draft.columns.filter((k) => keys.has(k)),
|
|
583
|
+
hidden: hiddenOf(draft).filter((k) => keys.has(k) && !shownKeys.includes(k)),
|
|
584
|
+
chips: chipsOf(draft).filter((c) => keys.has(c.key)),
|
|
585
|
+
sort: draft.sort && keys.has(draft.sort.key) ? draft.sort : null,
|
|
586
|
+
};
|
|
587
|
+
};
|
|
588
|
+
const save = () => {
|
|
589
|
+
if (!card) {
|
|
590
|
+
onSave?.(tableConfig());
|
|
591
|
+
onOpenChange(false);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (parsed) {
|
|
595
|
+
if (parsed.success)
|
|
596
|
+
onSaveCard?.(parsed.data, viewPart(draft));
|
|
597
|
+
}
|
|
598
|
+
else if (frame) {
|
|
599
|
+
// A table of its own: the frame, its name and its layout.
|
|
600
|
+
const next = tableConfig();
|
|
601
|
+
const own = mainTableOf(cardsOf(next));
|
|
602
|
+
onSaveCard?.(viewCardSchema.parse({
|
|
603
|
+
...Object.fromEntries(KEPT.filter((k) => frame[k] !== undefined).map((k) => [k, frame[k]])),
|
|
604
|
+
...(own?.controls ? { controls: own.controls } : {}),
|
|
605
|
+
...sourcePart,
|
|
606
|
+
kind: 'table',
|
|
607
|
+
style: 'table',
|
|
608
|
+
title: title.trim() || null,
|
|
609
|
+
filter: [],
|
|
610
|
+
table: tableLayoutOf(next),
|
|
611
|
+
}), viewPart(next));
|
|
612
|
+
}
|
|
613
|
+
onOpenChange(false);
|
|
614
|
+
};
|
|
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: {
|
|
626
|
+
cardFields: unlinked.fields.filter((f) => f.group !== FORMULAS_GROUP),
|
|
627
|
+
fieldsOf: (to) => allRead[to]?.domain.fields ?? [],
|
|
628
|
+
onOf: (to) => connections.find((c) => c.to === to)?.on ?? [],
|
|
629
|
+
set: setPairs,
|
|
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')
|
|
631
|
+
? 'This chart reads its symbols, not fields.'
|
|
632
|
+
: 'This widget takes no fields.' }));
|
|
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
|
|
634
|
+
? `Tick a field to use it as ${slot.label}.`
|
|
635
|
+
: `Tick fields to add them to ${slot.label}, up to ${slot.max}.` })] }))) : (_jsx("p", { className: "px-1.5 pb-2 text-xs text-muted-foreground", children: "Tick a field to add it as a column." }));
|
|
636
|
+
// More shows only what this view offers, and not at all when it offers nothing (an app view
|
|
637
|
+
// or a page's shared layout).
|
|
638
|
+
const more = [viewId && 'view id', onDuplicate && 'duplicate', onDelete && 'delete'].filter((x) => !!x);
|
|
639
|
+
const name = card ? title : draft.name;
|
|
640
|
+
const main = (_jsxs("div", { className: "flex flex-col", children: [_jsx(DrawerSection, { icon: ShapesIcon, title: "Widget", hint: styleLabel(styleId), ...section('widget'), children: _jsx(WidgetSection, { styleId: styleId, options: styleOptions, onStyle: card ? pickStyle : undefined, name: name, placeholder: card ? cardTitle({ ...(widget ?? frame ?? card), title: null }, config.name) : '', onName: (n) => (card ? setTitle(n) : setDraft({ ...draft, name: n })), icon: card ? undefined : _jsx(ViewIconPicker, { draft: draft, setDraft: setDraft }), frame: shown, onFrame: setFramePart }) }), widget ? (_jsx(DrawerSection, { icon: Columns3Icon, title: "Fields", hint: slots.map((s) => s.label).join(', ') || undefined, ...section('fields'), children: _jsx(WidgetFields, { card: widget, set: setWidget, domain: domain, rows: cashRows, fields: filterFields }, styleId) })) : (_jsx(DrawerSection, { icon: Columns3Icon, title: "Fields", count: shownKeys.length + hiddenKeys.length, hint: hiddenKeys.length
|
|
641
|
+
? `columns · ${shownKeys.length} shown · drag to reorder`
|
|
642
|
+
: 'columns · drag to reorder', ...section('fields'), children: _jsx(ColumnList, { base: base, domain: domain, draft: draft, setDraft: setDraft, shownKeys: shownKeys, hiddenKeys: hiddenKeys, onEditFormula: edit }) })), widget ? (hasWidgetSettings(widget) && (_jsx(DrawerSection, { icon: SlidersHorizontalIcon, title: "Settings", ...section('settings'), children: _jsx(WidgetSettings, { card: widget, set: setWidget, domain: domain }) }))) : (_jsx(DrawerSection, { icon: SlidersHorizontalIcon, title: "Settings", hint: "shape, sort, pages", ...section('settings'), children: _jsx(DetailsSection, { domain: domain, draft: draft, setDraft: setDraft, shownKeys: shownKeys }) })), (widget ? readsRows(widget) : !card) && (_jsx(DrawerSection, { icon: FilterIcon, title: "Rows", hint: widget ? 'which rows it counts' : 'the filters it opens with', ...section('rows'), children: widget ? (_jsx(WidgetRows, { rows: rows, fields: filterFields, tokens: tokens, onChange: setTokens, cash: isRowCard(widget) && domain.cash
|
|
643
|
+
? {
|
|
644
|
+
on: summaryCash(widget, tokens.length > 0),
|
|
645
|
+
set: (on) => setWidget({ ...widget, cash: on }),
|
|
646
|
+
}
|
|
647
|
+
: undefined })) : (_jsx(OpensWith, { draft: draft, setDraft: setDraft, current: current })) })), takesFormulas(domain) && (_jsx(DrawerSection, { icon: SigmaIcon, title: "Formulas", count: formulas.length, hint: "columns you compute", ...section('formulas'), children: _jsx(FormulasSection, { base: withLinks, draft: draft, setDraft: setDraft, formulaProblems: formulaProblems, blanks: blanks, editing: editing, setEditing: setEditing, onAdd: addFormula }) })), shown && (_jsx(DrawerSection, { icon: ToggleRightIcon, title: "Controls", hint: "what the widget's frame shows", ...section('controls'), children: _jsx(WidgetControlsEditor, { kind: widget?.kind ?? 'table', style: widget?.style ?? 'table', controls: shown.controls, onChange: (controls) => setFramePart({ controls }) }, styleId) })), editor && !widget && (_jsxs(DrawerSection, { icon: editor.icon, title: editor.title, ...section('desk'), children: [editor.description && (_jsx("p", { className: "mb-3 text-sm text-muted-foreground", children: editor.description })), editor.content] })), more.length > 0 && !card && (_jsx(DrawerSection, { icon: EllipsisIcon, title: "More", hint: more.join(', '), ...section('more'), children: _jsx(MoreSection, { name: config.name, viewId: viewId, onDuplicate: onDuplicate &&
|
|
648
|
+
(() => {
|
|
649
|
+
onOpenChange(false);
|
|
650
|
+
onDuplicate();
|
|
651
|
+
}), onDelete: onDelete &&
|
|
652
|
+
(() => {
|
|
653
|
+
onOpenChange(false);
|
|
654
|
+
onDelete();
|
|
655
|
+
}) }) })), parsed && !parsed.success && (_jsxs("p", { id: "view-editor-check", tabIndex: -1, className: "px-3 text-xs text-destructive", children: [parsed.error.issues[0]?.path.join('.'), ": ", parsed.error.issues[0]?.message] }))] }));
|
|
656
|
+
return (_jsxs(Dialog, { open: open, onOpenChange: onOpenChange, children: [_jsxs(DialogContent, { "data-slot": "view-editor", showCloseButton: false, className: "flex h-[min(780px,calc(100svh-2rem))] flex-col gap-0 overflow-hidden p-0 max-md:h-svh max-md:max-w-full max-md:rounded-none max-md:ring-0 sm:max-w-6xl", children: [_jsx(DialogDescription, { className: "sr-only", children: "This widget's type, fields, settings, rows and controls, and the fields it can use." }), _jsxs("header", { className: "flex shrink-0 items-center gap-2 border-b px-3 py-2 md:px-4", children: [isMobile && (_jsx(DialogClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close" }) })), _jsxs(DialogTitle, { className: "min-w-0 truncate text-sm font-semibold", children: ["Edit widget", ' ', _jsxs("span", { className: "font-normal text-muted-foreground", children: ["\u00B7 ", name.trim() || styleLabel(styleId)] })] }), _jsx("span", { className: "flex-1" }), !isMobile && (_jsx(DialogClose, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", children: [_jsx(XIcon, {}), "Cancel"] }) })), _jsx(SaveButton, { issues: issues, onSave: save })] }), isMobile ? (_jsxs(Tabs, { value: tab, onValueChange: (v) => setTab(v), className: "min-h-0 flex-1 gap-0 pt-2", children: [_jsxs(TabsList, { className: "mx-3 w-[calc(100%-1.5rem)]", children: [_jsxs(TabsTrigger, { value: "view", children: [_jsx(LayoutDashboardIcon, {}), "Widget"] }), _jsxs(TabsTrigger, { value: "fields", children: [_jsx(DatabaseIcon, {}), "Data"] })] }), _jsx(TabsContent, { value: "view", className: "min-h-0 overflow-y-auto px-2 py-2 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: main }), _jsxs(TabsContent, { value: "fields", className: "min-h-0 overflow-y-auto px-3 py-2", children: [treeHint, tree] })] })) : (_jsxs("div", { className: "grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_360px]", children: [_jsx("section", { "aria-label": "Widget", className: "min-h-0 overflow-y-auto px-3 py-2", children: main }), _jsxs("aside", { "aria-label": "Data", className: "flex min-h-0 flex-col overflow-y-auto border-l bg-sidebar px-2 py-2", children: [treeHint, tree, _jsxs("p", { className: "mt-auto px-2 pt-4 text-xs text-muted-foreground", children: ["Agents change the same settings with", ' ', _jsx("code", { className: "font-mono", children: card ? 'custom_view_widget_write' : 'custom_view_write' }), "."] })] })] }))] }), _jsx(AddFormulaDialog, { open: addingFormula, onOpenChange: setAddingFormula, domain: withLinks, formulas: formulas, onAdd: (f) => {
|
|
657
|
+
setAddingFormula(false);
|
|
658
|
+
setDraft({
|
|
659
|
+
...draft,
|
|
660
|
+
formulas: [...formulas, f],
|
|
661
|
+
columns: widget ? draft.columns : [...shownKeys, f.key],
|
|
662
|
+
});
|
|
663
|
+
} })] }));
|
|
664
|
+
}
|
|
665
|
+
const styleLabel = (id) => WIDGET_STYLES.find((w) => w.id === id)?.name ?? 'Table';
|
|
666
|
+
/** Widget: its type, name, width and link colour, the same for every widget. */
|
|
667
|
+
function WidgetSection({ styleId, options, onStyle, name, placeholder, onName, icon, frame, onFrame, }) {
|
|
668
|
+
const picker = (_jsx(LumenSelector, { label: "Widget type", size: "sm", className: "w-full min-w-0", options: options, value: styleId, onChange: (id) => onStyle?.(id), disabled: !onStyle }));
|
|
669
|
+
return (_jsxs("div", { className: "flex max-w-xl flex-col gap-3", children: [_jsx(Row, { label: "Type", children: onStyle ? (picker) : (_jsx(WhyOff, { note: "The view's own table stays a table", children: _jsx("span", { className: "w-full", children: picker }) })) }), _jsx(Row, { label: "Name", children: _jsxs(InputGroup, { children: [icon && _jsx(InputGroupAddon, { children: icon }), _jsx(InputGroupInput, { id: "view-editor-name", "aria-label": "Name", value: name, maxLength: icon ? undefined : CARD_TITLE_MAX, placeholder: placeholder, onChange: (e) => onName(e.target.value) })] }) }), frame && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Width", children: _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "justify-self-start", value: String(frame.w), onValueChange: (v) => v && onFrame({ w: Number(v) }), "aria-label": "Width in columns", children: CARD_COLUMNS.map((w) => (_jsx(ToggleGroupItem, { value: String(w), "aria-label": `${w} of 6 columns`, className: "px-2.5", children: w }, w))) }) }), _jsx(Row, { label: "Link", children: _jsxs("div", { className: "flex min-w-0 items-center gap-2 text-sm text-muted-foreground", children: [_jsx(LinkPicker, { color: frame.link ?? null, label: name || placeholder, onPick: (c) => onFrame({ link: c ?? undefined }) }), _jsx("span", { className: "min-w-0 truncate", children: frame.link ? 'Shares chips and tapped rows with its colour' : 'Not linked' })] }) })] }))] }));
|
|
670
|
+
}
|
|
671
|
+
/** Rows: the widget's own filter and, where the domain has cash rows, whether it counts them. */
|
|
672
|
+
function WidgetRows({ rows, fields, tokens, onChange, cash, }) {
|
|
673
|
+
const filter = useLumenFilter(rows, fields, { initial: tokens });
|
|
674
|
+
const next = JSON.stringify(filter.tokens);
|
|
675
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reports the box's tokens when they change
|
|
676
|
+
useEffect(() => {
|
|
677
|
+
if (next !== JSON.stringify(tokens))
|
|
678
|
+
onChange(filter.tokens);
|
|
679
|
+
}, [next]);
|
|
680
|
+
return (_jsxs("div", { className: "flex max-w-xl flex-col gap-3", children: [_jsx(Row, { label: "Only where", children: _jsx(LumenTokenizedFilter, { filter: filter, label: "Widget filter", placeholder: "All of the view's rows", link: null }) }), cash && (_jsx(Row, { label: "Cash", children: _jsxs(ToggleGroup, { type: "single", size: "sm", variant: "outline", spacing: 0, className: "justify-self-start", value: cash.on ? 'in' : 'out', onValueChange: (v) => v && cash.set(v === 'in'), "aria-label": "Cash", children: [_jsx(ToggleGroupItem, { value: "in", className: "px-2.5", children: "Include cash" }), _jsx(ToggleGroupItem, { value: "out", className: "px-2.5", children: "Leave out" })] }) }))] }));
|
|
681
|
+
}
|
|
682
|
+
/** Rows of the view's own table: the filters it opens with. */
|
|
683
|
+
function OpensWith({ draft, setDraft, current, }) {
|
|
684
|
+
return (_jsx(Row, { label: "Opens with", children: _jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-1.5", children: [draft.filters.default.map((t, i) => (_jsxs(Badge, { variant: "secondary", className: "gap-1 pr-0.5 font-normal", children: [tokenText(t), _jsx(IconButton, { icon: XIcon, size: "icon-xs", className: "size-5 rounded-full", label: `Remove filter ${tokenText(t)}`, onClick: () => setDraft({
|
|
685
|
+
...draft,
|
|
686
|
+
filters: {
|
|
687
|
+
...draft.filters,
|
|
688
|
+
default: draft.filters.default.filter((_, j) => j !== i),
|
|
689
|
+
},
|
|
690
|
+
}) })] }, tokenText(t)))), !draft.filters.default.length && (_jsx("span", { className: "text-sm text-muted-foreground", children: "No filters" })), _jsx(IconButton, { icon: FilterIcon, size: "icon-xs", label: "Use the page's filters", className: "text-muted-foreground", onClick: () => setDraft({ ...draft, filters: { ...draft.filters, default: current } }) })] }) }));
|
|
691
|
+
}
|
|
692
|
+
const MAX_PAIRS = 3;
|
|
693
|
+
/**
|
|
694
|
+
* A link's field pairs: up to 3, this card's field = the source's, each
|
|
695
|
+
* picked with LumenSelector. With none the link reads nothing; a shared key was filled in on add.
|
|
696
|
+
*/
|
|
697
|
+
function LinkPairs({ label, on, cardFields, fields, onChange, }) {
|
|
698
|
+
// The pair being picked: shown at once while there is none.
|
|
699
|
+
const [part, setPart] = useState(null);
|
|
700
|
+
const open = part ?? (on.length ? null : { from: '', to: '' });
|
|
701
|
+
const opts = (list) => list.map((f) => ({ value: f.key, label: f.label, ...(f.icon ? { icon: f.icon } : {}) }));
|
|
702
|
+
const mine = opts(cardFields);
|
|
703
|
+
const theirs = opts(fields);
|
|
704
|
+
const put = (p, i) => {
|
|
705
|
+
if (!p.from || !p.to)
|
|
706
|
+
return setPart(p);
|
|
707
|
+
setPart(null);
|
|
708
|
+
onChange(i === null ? [...on, p] : on.map((x, j) => (j === i ? p : x)));
|
|
709
|
+
};
|
|
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'));
|
|
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"] }))] }));
|
|
712
|
+
}
|
|
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, }) {
|
|
714
|
+
const assistantName = useAssistantName();
|
|
715
|
+
const [query, setQuery] = useState('');
|
|
716
|
+
// New recipe's box, open while not null.
|
|
717
|
+
const [wish, setWish] = useState(null);
|
|
718
|
+
// + Data: the
|
|
719
|
+
// app's domains this card can link, the person's recipes, and New recipe.
|
|
720
|
+
const dataGroup = `${deskLabel ?? 'This desk'} data`;
|
|
721
|
+
const dataOptions = [
|
|
722
|
+
...(onLink
|
|
723
|
+
? deskDomains.map((d) => ({
|
|
724
|
+
value: `link:${d.id}`,
|
|
725
|
+
label: d.label,
|
|
726
|
+
group: dataGroup,
|
|
727
|
+
detail: (d.on ? d.on.map((p) => p.from) : d.by).length
|
|
728
|
+
? `by ${(d.on ? d.on.map((p) => p.from) : d.by).map(fieldName).join(' + ')}`
|
|
729
|
+
: d.about,
|
|
730
|
+
icon: Link2Icon,
|
|
731
|
+
keywords: [d.about],
|
|
732
|
+
...(d.why ? { disabled: d.why } : {}),
|
|
733
|
+
}))
|
|
734
|
+
: []),
|
|
735
|
+
...(onAddRecipe
|
|
736
|
+
? recipes.map((f) => ({
|
|
737
|
+
value: `frame:${f.id}`,
|
|
738
|
+
label: f.name,
|
|
739
|
+
group: 'Recipes',
|
|
740
|
+
detail: `${recipeByKey(f) && f.key ? `by ${fieldName(f.key)}` : 'link by fields'} · ${asOf(f.at)}`,
|
|
741
|
+
icon: SparklesIcon,
|
|
742
|
+
keywords: f.columns.map((c) => c.label),
|
|
743
|
+
...(f.used && recipeByKey(f)
|
|
744
|
+
? { disabled: `Already enriches ${f.used}: a recipe joins one card` }
|
|
745
|
+
: {}),
|
|
746
|
+
}))
|
|
747
|
+
: []),
|
|
748
|
+
...(onNewRecipe
|
|
749
|
+
? [
|
|
750
|
+
{
|
|
751
|
+
value: 'new',
|
|
752
|
+
label: 'New recipe',
|
|
753
|
+
group: 'Recipes',
|
|
754
|
+
description: `Say what you want; ${assistantName} writes it`,
|
|
755
|
+
icon: PlusIcon,
|
|
756
|
+
},
|
|
757
|
+
]
|
|
758
|
+
: []),
|
|
759
|
+
];
|
|
760
|
+
const addData = (v) => {
|
|
761
|
+
if (v === 'new')
|
|
762
|
+
setWish('');
|
|
763
|
+
else if (v.startsWith('link:')) {
|
|
764
|
+
const d = deskDomains.find((x) => `link:${x.id}` === v);
|
|
765
|
+
if (d && !d.why)
|
|
766
|
+
onLink?.(d.id, d.on ?? []);
|
|
767
|
+
}
|
|
768
|
+
else if (v.startsWith('frame:')) {
|
|
769
|
+
const f = recipes.find((x) => `frame:${x.id}` === v);
|
|
770
|
+
if (f)
|
|
771
|
+
onAddRecipe?.(f);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
const q = query.trim().toLowerCase();
|
|
775
|
+
// Open groups and topics; a group or topic starts open when it has a shown field.
|
|
776
|
+
const [toggled, setToggled] = useState(new Map());
|
|
777
|
+
const formulas = formulasOf(draft);
|
|
778
|
+
const match = (f) => !q ||
|
|
779
|
+
f.label.toLowerCase().includes(q) ||
|
|
780
|
+
f.key.includes(q) ||
|
|
781
|
+
!!f.topic?.toLowerCase().includes(q);
|
|
782
|
+
const isOpen = (id, fields) => !!q || (toggled.get(id) ?? fields.some((f) => picked(f.key)));
|
|
783
|
+
const flip = (id, now) => setToggled(new Map(toggled).set(id, !now));
|
|
784
|
+
const leaf = (f) => {
|
|
785
|
+
const fi = formulas.findIndex((x) => x.key === f.key);
|
|
786
|
+
const off = why(f);
|
|
787
|
+
const box = (_jsx(Checkbox, { checked: picked(f.key), disabled: !!off, onCheckedChange: (v) => onPick(f.key, v === true), "aria-label": `Use ${f.label}` }));
|
|
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));
|
|
789
|
+
};
|
|
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(Unlink2Icon, { 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" })), unlinked && (_jsx(Badge, { variant: "destructive", className: "h-4 px-1.5 text-xs font-normal", children: "Not linked" })), _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: cn('px-1 py-1 text-xs', unlinked ? 'text-destructive' : '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
|
+
})] }));
|
|
825
|
+
}
|
|
826
|
+
/** A key's name as a person reads it: `symbol` is Symbol. */
|
|
827
|
+
const fieldName = (k) => (k[0]?.toUpperCase() ?? '') + k.slice(1).replace(/_/g, ' ');
|
|
828
|
+
/** A table's Settings: shape, the default grouping and sort, pages and width. */
|
|
829
|
+
function DetailsSection({ domain, draft, setDraft, shownKeys, }) {
|
|
830
|
+
const groupable = domain.fields.filter((f) => f.groupable);
|
|
831
|
+
const groupKeys = groupable.map((f) => f.key);
|
|
832
|
+
const numbers = domain.fields.filter((f) => f.format !== 'text');
|
|
833
|
+
const groupChoices = draft.group.fields.length ? draft.group.fields : groupKeys;
|
|
834
|
+
const offered = groupable.filter((f) => groupChoices.includes(f.key));
|
|
835
|
+
const byKey = new Map(domain.fields.map((f) => [f.key, f]));
|
|
836
|
+
const shown = shownKeys.map((k) => byKey.get(k)).filter((f) => !!f);
|
|
837
|
+
const pivot = draft.pivot;
|
|
838
|
+
const pagination = draft.pagination ?? viewPaginationSchema.parse({});
|
|
839
|
+
const fieldSelect = (label, value, fields, onChange, none) => (_jsx(LumenSelector, { label: label, size: "sm", className: "w-full min-w-0", placeholder: "Pick a field", options: [...(none ? [{ value: NONE, label: none }] : []), ...fieldOptions(fields, domain)], value: value || (none ? NONE : ''), onChange: onChange }));
|
|
840
|
+
return (_jsxs("div", { className: "flex max-w-xl flex-col gap-3", children: [_jsx(Row, { label: "Shape", children: _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "justify-self-start", "aria-label": "Shape", value: draft.shape, onValueChange: (v) => {
|
|
841
|
+
if (!v)
|
|
842
|
+
return;
|
|
843
|
+
const shape = v;
|
|
844
|
+
setDraft({
|
|
845
|
+
...draft,
|
|
846
|
+
shape,
|
|
847
|
+
group: {
|
|
848
|
+
...draft.group,
|
|
849
|
+
default: shape === 'table'
|
|
850
|
+
? null
|
|
851
|
+
: shape === 'group'
|
|
852
|
+
? (draft.group.default ?? offered[0]?.key ?? null)
|
|
853
|
+
: draft.group.default,
|
|
854
|
+
},
|
|
855
|
+
pivot: shape === 'pivot' && !draft.pivot
|
|
856
|
+
? {
|
|
857
|
+
rows: groupable[0]?.key ?? '',
|
|
858
|
+
columns: groupable[1]?.key ?? groupable[0]?.key ?? '',
|
|
859
|
+
value: numbers[0]?.key ?? '',
|
|
860
|
+
agg: 'sum',
|
|
861
|
+
}
|
|
862
|
+
: draft.pivot,
|
|
863
|
+
});
|
|
864
|
+
}, children: SHAPES.map((s) => {
|
|
865
|
+
const Icon = shapeIcon(s.value);
|
|
866
|
+
return (_jsxs(ToggleGroupItem, { value: s.value, "aria-label": s.label, children: [_jsx(Icon, {}), s.label] }, s.value));
|
|
867
|
+
}) }) }), draft.shape === 'group' && (_jsx(Row, { label: "Group by", children: fieldSelect('Default grouping', draft.group.default ?? '', offered, (v) => setDraft({ ...draft, group: { ...draft.group, default: v === NONE ? null : v } }), 'No grouping') })), subgroupsOf(domain).length ? (_jsx(Row, { label: subgroupLabel(domain), children: _jsx(LumenSelector, { label: subgroupLabel(domain), size: "sm", className: "w-full min-w-0", options: [
|
|
868
|
+
{ value: NONE, label: 'None' },
|
|
869
|
+
...subgroupsOf(domain).map((sg) => ({
|
|
870
|
+
value: sg.key,
|
|
871
|
+
label: sg.label,
|
|
872
|
+
icon: sg.icon,
|
|
873
|
+
})),
|
|
874
|
+
], value: subgroupOf(domain, draft.subgroup)?.key ?? NONE, onChange: (v) => setDraft({ ...draft, subgroup: v === NONE ? null : v }) }) })) : null, draft.shape === 'pivot' && pivot && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Rows", children: fieldSelect('Pivot rows', pivot.rows, groupable, (rows) => setDraft({ ...draft, pivot: { ...pivot, rows } })) }), _jsx(Row, { label: "Columns", children: fieldSelect('Pivot columns', pivot.columns, groupable, (columns) => setDraft({ ...draft, pivot: { ...pivot, columns } })) }), _jsx(Row, { label: "Value", children: _jsxs("div", { className: "flex min-w-0 gap-2", children: [fieldSelect('Pivot value', pivot.value, numbers, (value) => setDraft({ ...draft, pivot: { ...pivot, value } })), _jsx(LumenSelector, { label: "Roll-up", size: "sm", className: "w-28 shrink-0", options: viewAggs.map((a) => ({ value: a, label: AGG_LABELS[a] })), value: pivot.agg, onChange: (agg) => setDraft({ ...draft, pivot: { ...pivot, agg } }) })] }) })] })), _jsx(Row, { label: "Sort", children: _jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [fieldSelect('Sort by', draft.sort?.key ?? '', shown, (v) => setDraft({
|
|
875
|
+
...draft,
|
|
876
|
+
sort: v === NONE ? null : { key: v, desc: draft.sort?.desc ?? true },
|
|
877
|
+
}), 'As loaded'), _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, "aria-label": "Sort direction", disabled: !draft.sort, value: draft.sort?.desc ? 'desc' : 'asc', onValueChange: (v) => v && draft.sort && setDraft({ ...draft, sort: { ...draft.sort, desc: v === 'desc' } }), children: [_jsx(ToggleGroupItem, { value: "asc", "aria-label": "Ascending", children: _jsx(ArrowUpIcon, {}) }), _jsx(ToggleGroupItem, { value: "desc", "aria-label": "Descending", children: _jsx(ArrowDownIcon, {}) })] })] }) }), _jsx(Row, { label: "Pages", children: _jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Switch, { "aria-label": "Pagination", checked: pagination.on, onCheckedChange: (on) => setDraft({ ...draft, pagination: { ...pagination, on } }) }), _jsx(LumenSelector, { label: "Rows per page", size: "sm", className: "num w-36", disabled: !pagination.on, options: viewPageSizes.map((n) => ({ value: String(n), label: `${n} per page` })), value: String(pagination.size), onChange: (v) => setDraft({
|
|
878
|
+
...draft,
|
|
879
|
+
pagination: { ...pagination, size: Number(v) },
|
|
880
|
+
}) })] }) }), _jsx(Row, { label: "Table width", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: draft.width ?? 'scroll', onValueChange: (v) => v && setDraft({ ...draft, width: v }), "aria-label": "Table width", children: [_jsxs(ToggleGroupItem, { value: "fit", "aria-label": "Fit to width", children: [_jsx(ShrinkIcon, {}), " Fit"] }), _jsxs(ToggleGroupItem, { value: "scroll", "aria-label": "Scroll sideways", children: [_jsx(MoveHorizontalIcon, {}), " Scroll"] })] }) })] }));
|
|
881
|
+
}
|
|
882
|
+
const CHIP_LABELS = { text: 'Text', column: 'Chip', inline: 'On first' };
|
|
883
|
+
/**
|
|
884
|
+
* Why a box is greyed out, on hover or a tap (a disabled checkbox gets no pointer events, so its
|
|
885
|
+
* wrapper carries the tip).
|
|
886
|
+
*/
|
|
887
|
+
function WhyOff({ note, children }) {
|
|
888
|
+
const [open, setOpen] = useState(false);
|
|
889
|
+
return (_jsxs(Tooltip, { open: open, onOpenChange: setOpen, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("span", { className: "flex justify-center", onPointerDown: () => setOpen(true), children: children }) }), _jsx(TooltipContent, { children: note })] }));
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Columns: the fields the view lists, in order, dragged to reorder. Show puts one on screen; a
|
|
893
|
+
* field not shown stays listed, dimmed, so it can still be ticked to filter or group by. Each is shown as text or a chip.
|
|
894
|
+
*/
|
|
895
|
+
function ColumnList({ base, domain, draft, setDraft, shownKeys, hiddenKeys, onEditFormula, }) {
|
|
896
|
+
const sensors = useDrag();
|
|
897
|
+
const baseByKey = new Map(base.fields.map((f) => [f.key, f]));
|
|
898
|
+
// Total: what group and Total rows show for a number column. A formula's
|
|
899
|
+
// pick is its own `agg`; a field's goes in `aggs`, and picking the field's own clears it.
|
|
900
|
+
const totalCell = (f) => {
|
|
901
|
+
const formula = formulasOf(draft).find((x) => x.key === f.key);
|
|
902
|
+
const own = baseByKey.get(f.key);
|
|
903
|
+
const ownPick = formula ? null : own ? totalAggOf(own) : 'none';
|
|
904
|
+
const ownLabel = !formula && own?.share ? 'Share of total' : 'Same formula';
|
|
905
|
+
const options = [
|
|
906
|
+
...(formula || ownPick === 'own' ? [{ value: OWN_TOTAL, label: ownLabel }] : []),
|
|
907
|
+
{ value: 'none', label: 'None' },
|
|
908
|
+
...TOTAL_AGGS.map((a) => ({ value: a, label: AGG_LABELS[a] })),
|
|
909
|
+
];
|
|
910
|
+
const value = formula
|
|
911
|
+
? (formula.agg ?? OWN_TOTAL)
|
|
912
|
+
: (draft.aggs?.[f.key] ?? (ownPick === 'own' ? OWN_TOTAL : ownPick));
|
|
913
|
+
const set = (v) => {
|
|
914
|
+
if (formula) {
|
|
915
|
+
setDraft({
|
|
916
|
+
...draft,
|
|
917
|
+
formulas: formulasOf(draft).map((x) => x.key === f.key ? { ...x, agg: v === OWN_TOTAL ? null : v } : x),
|
|
918
|
+
});
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const { [f.key]: _, ...rest } = draft.aggs ?? {};
|
|
922
|
+
const fieldOwn = ownPick === 'own' ? OWN_TOTAL : ownPick;
|
|
923
|
+
setDraft({
|
|
924
|
+
...draft,
|
|
925
|
+
aggs: v === fieldOwn ? rest : { ...rest, [f.key]: v },
|
|
926
|
+
});
|
|
927
|
+
};
|
|
928
|
+
return (_jsx(LumenSelector, { label: `Total of ${f.label}`, size: "sm", className: "h-7 w-full", options: options, value: value ?? 'none', onChange: set }));
|
|
929
|
+
};
|
|
930
|
+
const byKey = new Map(domain.fields.map((f) => [f.key, f]));
|
|
931
|
+
// Rows keep their place while Show is ticked on and off; a saved view lists the shown first.
|
|
932
|
+
const [order, setOrder] = useState(() => [...shownKeys, ...hiddenKeys]);
|
|
933
|
+
const listedKeys = [...shownKeys, ...hiddenKeys];
|
|
934
|
+
const rowKeys = [
|
|
935
|
+
...order.filter((k) => listedKeys.includes(k)),
|
|
936
|
+
...listedKeys.filter((k) => !order.includes(k)),
|
|
937
|
+
];
|
|
938
|
+
const rows = rowKeys.map((k) => byKey.get(k)).filter((f) => !!f);
|
|
939
|
+
const chips = chipsOf(draft);
|
|
940
|
+
const first = domain.fields.find((f) => f.key === shownKeys[0]);
|
|
941
|
+
const groupKeys = domain.fields.filter((f) => f.groupable).map((f) => f.key);
|
|
942
|
+
const filterKeys = domain.fields.filter((f) => f.filterable).map((f) => f.key);
|
|
943
|
+
const canFilterKeys = domain.fields.filter(canFilter).map((f) => f.key);
|
|
944
|
+
const groupChoices = draft.group.fields.length ? draft.group.fields : groupKeys;
|
|
945
|
+
const filterChoices = draft.filters.fields.length ? draft.filters.fields : filterKeys;
|
|
946
|
+
const groupInfo = (f) => {
|
|
947
|
+
const id = groupOf(domain, f);
|
|
948
|
+
if (id === FORMULAS_GROUP)
|
|
949
|
+
return { color: PLAIN, label: 'Formula' };
|
|
950
|
+
const g = domain.groups?.find((x) => x.id === id);
|
|
951
|
+
// A column chip takes its source's colour like its header.
|
|
952
|
+
const src = f.topic ? g?.sources?.[f.topic] : undefined;
|
|
953
|
+
return { color: src?.color ?? g?.color ?? PLAIN, label: f.topic ?? g?.label ?? '' };
|
|
954
|
+
};
|
|
955
|
+
const chippable = (f) => f.format === 'text' && (f.filterable || f.groupable) && f.key !== first?.key;
|
|
956
|
+
const placement = (key) => chips.find((c) => c.key === key)?.placement ?? CHIP_TEXT;
|
|
957
|
+
const setPlacement = (key, v) => setDraft({
|
|
958
|
+
...draft,
|
|
959
|
+
chips: v === CHIP_TEXT
|
|
960
|
+
? chips.filter((c) => c.key !== key)
|
|
961
|
+
: chips.some((c) => c.key === key)
|
|
962
|
+
? chips.map((c) => (c.key === key ? { key, placement: v } : c))
|
|
963
|
+
: [...chips, { key, placement: v }],
|
|
964
|
+
});
|
|
965
|
+
const isFormula = (key) => formulasOf(draft).some((x) => x.key === key);
|
|
966
|
+
// Show on or off keeps the row where it is: columns follow the rows' order.
|
|
967
|
+
const setShown = (key, on) => {
|
|
968
|
+
setOrder(rowKeys);
|
|
969
|
+
const shown = new Set(shownKeys);
|
|
970
|
+
if (on)
|
|
971
|
+
shown.add(key);
|
|
972
|
+
else
|
|
973
|
+
shown.delete(key);
|
|
974
|
+
setDraft({
|
|
975
|
+
...draft,
|
|
976
|
+
columns: rowKeys.filter((k) => shown.has(k)),
|
|
977
|
+
hidden: rowKeys.filter((k) => !shown.has(k)),
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
const groupNote = (f) => f.groupNote ??
|
|
981
|
+
(f.format === 'text' ? 'Not offered for grouping.' : 'Numbers can’t be grouped.');
|
|
982
|
+
if (!rows.length)
|
|
983
|
+
return _jsx("p", { className: "text-sm text-muted-foreground", children: "Tick fields on the right to show them." });
|
|
984
|
+
const grid = 'grid grid-cols-[1.25rem_minmax(0,1fr)_2.5rem_2.5rem_2.5rem] items-center gap-x-2 md:grid-cols-[1.25rem_minmax(0,1fr)_3.5rem_3.5rem_3.5rem_8.5rem_7.5rem]';
|
|
985
|
+
return (_jsxs("div", { className: "flex flex-col", "data-testid": "view-columns", children: [_jsxs("div", { className: cn(grid, 'pb-1 text-xs text-muted-foreground'), children: [_jsx("span", {}), _jsx("span", { children: "Column" }), _jsx("span", { className: "text-center", children: "Show" }), _jsx("span", { className: "text-center", children: "Filter" }), _jsx("span", { className: "text-center", children: "Group" }), _jsx("span", { className: "max-md:hidden", children: "Format" }), _jsx("span", { className: "max-md:hidden", children: "Total" })] }), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: (e) => {
|
|
986
|
+
const next = moved(rowKeys, e);
|
|
987
|
+
setOrder(next);
|
|
988
|
+
setDraft({
|
|
989
|
+
...draft,
|
|
990
|
+
columns: next.filter((k) => shownKeys.includes(k)),
|
|
991
|
+
hidden: next.filter((k) => !shownKeys.includes(k)),
|
|
992
|
+
});
|
|
993
|
+
}, children: _jsx(SortableContext, { items: rowKeys, strategy: verticalListSortingStrategy, children: rows.map((f) => {
|
|
994
|
+
const g = groupInfo(f);
|
|
995
|
+
const shown = shownKeys.includes(f.key);
|
|
996
|
+
const canF = canFilterKeys.includes(f.key);
|
|
997
|
+
const canG = groupKeys.includes(f.key);
|
|
998
|
+
return (_jsxs(ColumnRow, { id: f.key, className: grid, children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: cn('h-4 w-1 shrink-0 rounded-full', !shown && 'opacity-40'), style: { background: g.color }, "aria-hidden": true }), _jsx("span", { className: cn('truncate', !shown && 'text-muted-foreground'), children: f.label }), _jsx("span", { className: "truncate text-xs text-muted-foreground max-md:hidden", children: g.label }), !shown && (_jsx(Badge, { variant: "outline", className: "shrink-0 font-normal text-muted-foreground", children: "Not shown" })), isFormula(f.key) && (_jsx(IconButton, { icon: PencilIcon, size: "icon-xs", className: "text-muted-foreground", label: `Edit formula ${f.label}`, onClick: () => onEditFormula(f.key) }))] }), _jsx("span", { className: "flex justify-center", children: _jsx(Checkbox, { "aria-label": `Show ${f.label}`, checked: shown, onCheckedChange: (v) => setShown(f.key, v === true) }) }), canF ? (_jsx("span", { className: "flex justify-center", children: _jsx(Checkbox, { "aria-label": `Filter by ${f.label}`, checked: filterChoices.includes(f.key), onCheckedChange: (v) => setDraft({
|
|
999
|
+
...draft,
|
|
1000
|
+
filters: {
|
|
1001
|
+
...draft.filters,
|
|
1002
|
+
fields: toggleIn(draft.filters.fields, filterKeys, f.key, v === true, canFilterKeys),
|
|
1003
|
+
default: v === true
|
|
1004
|
+
? draft.filters.default
|
|
1005
|
+
: draft.filters.default.filter((t) => t.key !== f.key),
|
|
1006
|
+
},
|
|
1007
|
+
}) }) })) : (_jsx(WhyOff, { note: "Not offered as a filter.", children: _jsx(Checkbox, { "aria-label": `Filter by ${f.label}`, disabled: true, checked: false }) })), canG ? (_jsx("span", { className: "flex justify-center", children: _jsx(Checkbox, { "aria-label": `Group by ${f.label}`, checked: groupChoices.includes(f.key), onCheckedChange: (v) => setDraft({
|
|
1008
|
+
...draft,
|
|
1009
|
+
group: {
|
|
1010
|
+
...draft.group,
|
|
1011
|
+
fields: toggleIn(draft.group.fields, groupKeys, f.key, v === true),
|
|
1012
|
+
default: v !== true && draft.group.default === f.key
|
|
1013
|
+
? null
|
|
1014
|
+
: draft.group.default,
|
|
1015
|
+
},
|
|
1016
|
+
}) }) })) : (_jsx(WhyOff, { note: groupNote(f), children: _jsx(Checkbox, { "aria-label": `Group by ${f.label}`, disabled: true, checked: false }) })), _jsx("span", { className: "max-md:hidden", children: chippable(f) ? (_jsx(LumenSelector, { label: `Show ${f.label} as`, size: "sm", className: "h-7 w-full", options: Object.entries(CHIP_LABELS).map(([v, l]) => ({
|
|
1017
|
+
value: v,
|
|
1018
|
+
label: v === 'inline' ? `On ${first?.label ?? 'first'}` : l,
|
|
1019
|
+
})), value: placement(f.key), onChange: (v) => setPlacement(f.key, v) })) : f.format !== 'text' ? (_jsx(LumenSelector, { label: `Format of ${f.label}`, size: "sm", className: "h-7 w-full", options: formatOptions(), value: f.format, onChange: (v) => setDraft({
|
|
1020
|
+
...draft,
|
|
1021
|
+
formats: {
|
|
1022
|
+
...draft.formats,
|
|
1023
|
+
[f.key]: v,
|
|
1024
|
+
},
|
|
1025
|
+
}) })) : (_jsx("span", { className: "pl-2 text-xs text-muted-foreground", children: FORMAT_LABELS[f.format] })) }), _jsx("span", { className: "max-md:hidden", children: f.format !== 'text' && totalCell(f) })] }, f.key));
|
|
1026
|
+
}) }) })] }));
|
|
1027
|
+
}
|
|
1028
|
+
function ColumnRow({ id, className, children, }) {
|
|
1029
|
+
const s = useSortable({ id });
|
|
1030
|
+
return (_jsxs("div", { ref: s.setNodeRef, "data-field": id, style: { transform: CSS.Transform.toString(s.transform), transition: s.transition }, className: cn(className, 'min-h-9 rounded-md border-b border-border/50 bg-card text-sm last:border-b-0 hover:bg-accent/40', s.isDragging && 'relative z-20 shadow-md'), children: [_jsx(Button, { variant: "ghost", size: "icon-xs", className: "size-5 cursor-grab touch-none text-muted-foreground active:cursor-grabbing", "aria-label": `Drag ${id}`, ...s.attributes, ...s.listeners, children: _jsx(GripVerticalIcon, {}) }), children] }));
|
|
1031
|
+
}
|
|
1032
|
+
/** Formulas: each one a sub-item with its own editor, then New formula. */
|
|
1033
|
+
function FormulasSection({ base, draft, setDraft, formulaProblems, blanks, editing, setEditing, onAdd, }) {
|
|
1034
|
+
const formulas = formulasOf(draft);
|
|
1035
|
+
return (_jsxs("div", { className: "flex flex-col gap-1", children: [!formulas.length && (_jsx("p", { className: "text-sm text-muted-foreground", children: "A formula is a column you compute from other fields, like weight or upside." })), formulas.map((formula, fi) => {
|
|
1036
|
+
const open = editing === formula.key;
|
|
1037
|
+
const readable = formulaOptions(base, formulas, formula.key);
|
|
1038
|
+
return (_jsxs(Collapsible
|
|
1039
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: the key is edited in place; the index keeps the editor mounted.
|
|
1040
|
+
, { open: open, onOpenChange: (o) => setEditing(o ? formula.key : null), className: "rounded-md border", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "sm", className: "w-full justify-start gap-2 px-2", children: [_jsx(ChevronRightIcon, { className: cn('text-muted-foreground transition-transform', open && 'rotate-90') }), _jsx(SigmaIcon, { className: "text-muted-foreground" }), _jsx("span", { className: "shrink-0", children: formula.label || formula.key }), _jsx("code", { className: "min-w-0 truncate font-mono text-xs font-normal text-muted-foreground", children: formula.expr || 'no expression yet' }), formulaProblems[fi] && (_jsx(Badge, { variant: "destructive", className: "ml-auto font-normal", children: "fix" }))] }) }), _jsx(CollapsibleContent, { className: "[&>div]:border-t [&>div]:bg-transparent [&>div]:md:pl-3", children: _jsx(FormulaForm, { formula: formula, issue: formulaProblems[fi] ?? null, blank: blanks.get(formula.key) ?? null, fields: readable, onChange: (p) => {
|
|
1041
|
+
const next = formulas.map((x, j) => (j === fi ? { ...x, ...p } : x));
|
|
1042
|
+
// A renamed key carries its column, chip, sort and tiles with it.
|
|
1043
|
+
const was = formula.key;
|
|
1044
|
+
const now = p.key ?? was;
|
|
1045
|
+
const rename = (k) => (k === was ? now : k);
|
|
1046
|
+
setDraft({
|
|
1047
|
+
...draft,
|
|
1048
|
+
formulas: next,
|
|
1049
|
+
columns: draft.columns.map(rename),
|
|
1050
|
+
tiles: tilesOf(draft).map((t) => renameTile(t, rename)),
|
|
1051
|
+
cards: draft.cards?.map((c) => isRowCard(c) && c.tiles
|
|
1052
|
+
? { ...c, tiles: c.tiles.map((t) => renameTile(t, rename)) }
|
|
1053
|
+
: c),
|
|
1054
|
+
sort: draft.sort ? { ...draft.sort, key: rename(draft.sort.key) } : null,
|
|
1055
|
+
});
|
|
1056
|
+
if (now !== was)
|
|
1057
|
+
setEditing(now);
|
|
1058
|
+
}, onRemove: () => {
|
|
1059
|
+
setEditing(null);
|
|
1060
|
+
setDraft({
|
|
1061
|
+
...draft,
|
|
1062
|
+
formulas: formulas.filter((_, j) => j !== fi),
|
|
1063
|
+
columns: draft.columns.filter((k) => k !== formula.key),
|
|
1064
|
+
});
|
|
1065
|
+
}, onDone: () => setEditing(null) }) })] }, fi));
|
|
1066
|
+
}), onAdd && (_jsx(IconButton, { icon: PlusIcon, label: "New formula", className: "self-start text-primary", onClick: onAdd }))] }));
|
|
1067
|
+
}
|
|
1068
|
+
/** The sidebar icon: the shared picker over the draft's `icon`. */
|
|
1069
|
+
function ViewIconPicker({ draft, setDraft }) {
|
|
1070
|
+
return (_jsx(IconPicker, { value: draft.icon, onChange: (icon) => setDraft({ ...draft, icon }), fallback: shapeIcon(draft.shape), label: "Sidebar icon", resetLabel: "Use the shape's icon" }));
|
|
1071
|
+
}
|
|
1072
|
+
function MoreSection({ name, viewId, onDuplicate, onDelete, }) {
|
|
1073
|
+
const [copied, setCopied] = useState(false);
|
|
1074
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [viewId && (_jsxs(Field, { label: "View id", children: [_jsx("p", { className: "text-xs text-muted-foreground", children: "Give it to an agent to read or change this view (custom_view_read, custom_view_write)." }), _jsxs(InputGroup, { children: [_jsx(InputGroupInput, { "aria-label": "View id", readOnly: true, value: viewId, className: "font-mono text-xs", onFocus: (e) => e.currentTarget.select() }), _jsx(InputGroupAddon, { align: "inline-end", children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": "Copy view id", onClick: () => void navigator.clipboard.writeText(viewId).then(() => {
|
|
1075
|
+
setCopied(true);
|
|
1076
|
+
setTimeout(() => setCopied(false), 1500);
|
|
1077
|
+
}), children: copied ? _jsx(CheckIcon, {}) : _jsx(CopyIcon, {}) }) })] })] })), onDuplicate && (_jsx(Field, { label: "Duplicate", children: _jsx(IconButton, { icon: CopyIcon, label: "Duplicate view", variant: "outline", className: "self-start", onClick: onDuplicate }) })), onDelete && (_jsxs("div", { className: "flex items-center gap-3 rounded-lg border border-destructive/40 p-4", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "font-medium", children: "Delete view" }), _jsx("span", { className: "text-sm text-muted-foreground", children: "Removes it for you. You confirm first." })] }), _jsx(LumenConfirm, { title: `Delete view ${name}?`, onConfirm: onDelete, children: _jsx(IconButton, { icon: Trash2Icon, label: "Delete view", variant: "destructive" }) })] }))] }));
|
|
1078
|
+
}
|
|
1079
|
+
function Field({ label, children }) {
|
|
1080
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-sm font-medium", children: label }), children] }));
|
|
1081
|
+
}
|