@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,661 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// LumenLayout: the page
|
|
3
|
+
// every app shows. Crumbs and actions in the header, then the filter and Group by switch every
|
|
4
|
+
// card on the page shares, then an ordered list of cards, each a third, half, two thirds or the full width:
|
|
5
|
+
// widgets (LumenSavedWidget: tiles, charts, top N, market, price chart) and views (LumenView, the domain's
|
|
6
|
+
// table). A saved view's `cards` say which and in what order, and Edit layout changes them; a page
|
|
7
|
+
// can add cards of its own in code (`cards`), views of other data among them. The frame paints at
|
|
8
|
+
// once and each card loads its own data (lumen-source.tsx).
|
|
9
|
+
import { linkColumns } from '@columenlabs/lumen-ui-types/catalog';
|
|
10
|
+
import { sourceLine } from '@columenlabs/lumen-ui-types/domains';
|
|
11
|
+
import { CONNECTED_COUNT, GRID_COLS, cardsOf, connectedKey, controlOn, isOwnTile, isRowCard, isTableCard, mainTableOf, tableConfigOf, tableLayoutOf, tilesOfCard, } from '@columenlabs/lumen-ui-types/views';
|
|
12
|
+
import { LayoutDashboardIcon, PlusIcon, SaveIcon, TableIcon, Undo2Icon, } from 'lucide-react';
|
|
13
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
14
|
+
import { AddFormulaDialog, YourDataDialog } from './add-column.js';
|
|
15
|
+
import { useAssistant } from './assistant-context.js';
|
|
16
|
+
import { useAssistantNote } from './assistant-context.js';
|
|
17
|
+
import { useAssistantMoves } from './assistant-screen.js';
|
|
18
|
+
import { ConnectOffer } from './connections.js';
|
|
19
|
+
import { usePageKey } from './fold.js';
|
|
20
|
+
import { useGroup } from './group-button.js';
|
|
21
|
+
import { useIsMobile } from './hooks/use-mobile.js';
|
|
22
|
+
import { SourceStatusItems, useJoins } from './joins.js';
|
|
23
|
+
import { useYourData } from './joins.js';
|
|
24
|
+
import { cn } from './lib/utils.js';
|
|
25
|
+
import { LumenDrawer } from './lumen-drawer.js';
|
|
26
|
+
import { LumenTokenizedFilter, filterByTokens, useLumenFilter, } from './lumen-filter.js';
|
|
27
|
+
import { LumenGrid } from './lumen-grid.js';
|
|
28
|
+
import { KEY_FIELDS, LinksProvider, followedBy, followsText, keyFilterFields, shared, useLinkState, useLinks, usePublishLinks, useRowPick, } from './lumen-links.js';
|
|
29
|
+
import { LumenStates, useCardLoads } from './lumen-source.js';
|
|
30
|
+
import { DetailsBody, LumenView, VIEW_PAGE, VIEW_SCROLL, mergeRows, } from './lumen-view.js';
|
|
31
|
+
import { LumenViewCard } from './lumen-view-card.js';
|
|
32
|
+
import { AddWidgetDialog, LayoutActions, LayoutChip, LumenSavedWidget, LumenWidget, cardFilterFields, cardTitle, cardsAfter, } from './lumen-widget.js';
|
|
33
|
+
import { OriginLegend } from './origin-legend.js';
|
|
34
|
+
import { IconButton, Page } from './page.js';
|
|
35
|
+
import { SourcedCard } from './sourced-card.js';
|
|
36
|
+
import { LumenCard, SummaryTiles } from './summary-cards.js';
|
|
37
|
+
import { Button } from './ui/button.js';
|
|
38
|
+
import { ViewEditDialog } from './view-editor.js';
|
|
39
|
+
import { computedOf, filterFieldsOf, formulaBlanks, pick, subgroupOf, summaryCash, takesFormulas, withSavedFilters, withViewFields, } from './view-model.js';
|
|
40
|
+
/** Rows of a view the assistant sees with each message. */
|
|
41
|
+
const ASSISTANT_ROWS = 10;
|
|
42
|
+
export function LumenLayout(props) {
|
|
43
|
+
if (props.domain)
|
|
44
|
+
return _jsx(ViewLayout, { ...props });
|
|
45
|
+
return _jsx(CardsLayout, { ...props });
|
|
46
|
+
}
|
|
47
|
+
/** One code-built card in its frame, with its own states, its square and what it follows. */
|
|
48
|
+
function CodeCard({ card, inScroll, controls, }) {
|
|
49
|
+
const links = useLinks();
|
|
50
|
+
const color = links?.colorOf(card.id) ?? null;
|
|
51
|
+
const follows = useCardFollows(card);
|
|
52
|
+
const sharedKey = JSON.stringify(card.publishes ?? []);
|
|
53
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on what it shares.
|
|
54
|
+
const publishes = useMemo(() => card.publishes ?? [], [sharedKey]);
|
|
55
|
+
usePublishLinks(card.id, publishes);
|
|
56
|
+
const link = card.link ??
|
|
57
|
+
(links
|
|
58
|
+
? {
|
|
59
|
+
color,
|
|
60
|
+
onPick: (c) => links.setColor(card.id, c),
|
|
61
|
+
follows: follows.length ? followsText(follows) : undefined,
|
|
62
|
+
}
|
|
63
|
+
: undefined);
|
|
64
|
+
const children = typeof card.children === 'function' ? card.children(follows) : card.children;
|
|
65
|
+
return (_jsx(LumenWidget, { id: card.id, kind: null, style: card.kind === 'table' ? 'table' : undefined, icon: card.icon ?? (card.kind === 'table' ? TableIcon : undefined), title: card.title, controls: controls, load: card.load, rows: card.rows, tools: card.tools, help: card.help, assistant: card.assistant, link: link, pad: card.kind !== 'table', pinned: inScroll && card.kind === 'table', children: card.load ? (_jsx(LumenStates, { load: card.load, kind: card.kind === 'table' ? 'table' : 'widget', skeleton: card.skeleton, children: children })) : (children) }));
|
|
66
|
+
}
|
|
67
|
+
/** The chips of its colour a code card follows: those on the keys it `reads`. */
|
|
68
|
+
function useCardFollows(card) {
|
|
69
|
+
const links = useLinks();
|
|
70
|
+
const all = links?.follows(card.id) ?? NO_TOKENS;
|
|
71
|
+
const key = JSON.stringify(all);
|
|
72
|
+
const reads = (card.reads ?? []).join();
|
|
73
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the chips and keys.
|
|
74
|
+
return useMemo(() => {
|
|
75
|
+
const keys = reads.split(',');
|
|
76
|
+
// A chip on another domain's key field (underlying, ticker) reaches it by key.
|
|
77
|
+
return all.filter((t) => keys.includes(t.key) || (t.as && KEY_FIELDS[t.as].some((k) => keys.includes(k))));
|
|
78
|
+
}, [key, reads]);
|
|
79
|
+
}
|
|
80
|
+
const NO_TOKENS = [];
|
|
81
|
+
/** The keys a code card over a domain's rows reads (its `reads`). */
|
|
82
|
+
export const domainKeys = (domain) => domain.fields.map((f) => f.key);
|
|
83
|
+
/** A code card's rows narrowed by the chips it follows, read on the domain's fields. */
|
|
84
|
+
export function followRows(domain, rows, follows) {
|
|
85
|
+
if (!follows.length)
|
|
86
|
+
return rows;
|
|
87
|
+
const fields = [...cardFilterFields(domain.fields), ...keyFilterFields(domain.keys)];
|
|
88
|
+
return filterByTokens(rows, fields, followedBy(follows, fields, domain.keys), domain.text);
|
|
89
|
+
}
|
|
90
|
+
/** The page's colours when it opens: its saved cards' and its code cards' own. */
|
|
91
|
+
const codeColors = (cards) => Object.fromEntries(cards.flatMap((cs) => (cs ?? []).filter((c) => c.color).map((c) => [c.id, c.color ?? null])));
|
|
92
|
+
/** The page's code-built cards, whose colour picks the browser remembers for this route. */
|
|
93
|
+
function useKeptColors(cards) {
|
|
94
|
+
const page = usePageKey();
|
|
95
|
+
const ids = cards.flatMap((cs) => (cs ?? []).map((c) => c.id));
|
|
96
|
+
return { page, ids };
|
|
97
|
+
}
|
|
98
|
+
// A page's intro row. When it holds tabs it sticks to the top as the page scrolls, under the header,
|
|
99
|
+
// on every app ("the tab should be sticky to the top"). It also covers the
|
|
100
|
+
// gutters on both sides with shadows, so neither a card border nor a wide table scrolled under it
|
|
101
|
+
// peeks out beside it; shadows, unlike a wider box, never make the page scroll sideways.
|
|
102
|
+
const INTRO = 'sticky left-0 flex w-(--view-w) flex-col gap-(--shell-section-gap) has-[[data-slot=tabs-list]]:top-0 has-[[data-slot=tabs-list]]:z-30 has-[[data-slot=tabs-list]]:bg-background has-[[data-slot=tabs-list]]:shadow-[var(--shell-gutter)_0_0_0_var(--color-background),calc(-1*var(--shell-gutter))_0_0_0_var(--color-background)]';
|
|
103
|
+
/** The page's one scroller: cards, filter and table scroll together. */
|
|
104
|
+
function Scroller({ fit, children }) {
|
|
105
|
+
// Tables' pinned headers stop under a sticky tab row (--pin-top), not behind it. The intro can
|
|
106
|
+
// mount after the scroller (a page that loads first), so each render checks which one it is.
|
|
107
|
+
// A theme that keeps the tab row looking clear (glass sets --intro-fade) paints the page behind
|
|
108
|
+
// it (theme.css): this lines that paint up with the fixed wash and stops pinned headers below its
|
|
109
|
+
// fade. Nothing here runs on scroll: the row is sticky, so it never moves on screen.
|
|
110
|
+
const ref = useRef(null);
|
|
111
|
+
const watched = useRef(null);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const el = ref.current;
|
|
114
|
+
const intro = el?.querySelector('[data-slot=page-intro]') ?? null;
|
|
115
|
+
if (!el || watched.current?.intro === intro)
|
|
116
|
+
return;
|
|
117
|
+
watched.current?.off();
|
|
118
|
+
watched.current = null;
|
|
119
|
+
if (!intro)
|
|
120
|
+
return el.style.setProperty('--pin-top', '0px');
|
|
121
|
+
const set = () => {
|
|
122
|
+
const css = getComputedStyle(el);
|
|
123
|
+
const fade = Number.parseFloat(css.getPropertyValue('--intro-fade')) || 0;
|
|
124
|
+
const gutter = Number.parseFloat(css.getPropertyValue('--shell-gutter')) || 0;
|
|
125
|
+
const box = intro.getBoundingClientRect();
|
|
126
|
+
const root = document.documentElement;
|
|
127
|
+
// Rounded down, so a pinned header tucks under the row's last fraction of a pixel instead
|
|
128
|
+
// of leaving a sliver between them that rows show through.
|
|
129
|
+
const row = intro.querySelector('[data-slot=tabs-list]') ? Math.floor(box.height) : 0;
|
|
130
|
+
el.style.setProperty('--pin-top', `${row ? row + fade : 0}px`);
|
|
131
|
+
intro.style.setProperty('--wash-x', `${box.left - gutter}px`);
|
|
132
|
+
intro.style.setProperty('--wash-y', `${box.top}px`);
|
|
133
|
+
intro.style.setProperty('--wash-w', `${root.clientWidth}px`);
|
|
134
|
+
intro.style.setProperty('--wash-h', `${root.clientHeight}px`);
|
|
135
|
+
};
|
|
136
|
+
set();
|
|
137
|
+
const ro = new ResizeObserver(set);
|
|
138
|
+
ro.observe(intro);
|
|
139
|
+
ro.observe(el);
|
|
140
|
+
window.addEventListener('resize', set);
|
|
141
|
+
// The theme can change while the page is open (Settings ▸ glass).
|
|
142
|
+
const mo = new MutationObserver(set);
|
|
143
|
+
mo.observe(document.documentElement, { attributes: true });
|
|
144
|
+
const off = () => {
|
|
145
|
+
ro.disconnect();
|
|
146
|
+
mo.disconnect();
|
|
147
|
+
window.removeEventListener('resize', set);
|
|
148
|
+
};
|
|
149
|
+
watched.current = { intro, off };
|
|
150
|
+
});
|
|
151
|
+
useEffect(() => () => watched.current?.off(), []);
|
|
152
|
+
// The vertical scrollbar's width (0 where bars overlay), so pinned rows fit beside it.
|
|
153
|
+
useLayoutEffect(() => {
|
|
154
|
+
const el = ref.current;
|
|
155
|
+
if (!el)
|
|
156
|
+
return;
|
|
157
|
+
const measure = () => el.style.setProperty('--view-sb', `${el.offsetWidth - el.clientWidth}px`);
|
|
158
|
+
measure();
|
|
159
|
+
const seen = new ResizeObserver(measure);
|
|
160
|
+
seen.observe(el);
|
|
161
|
+
if (el.firstElementChild)
|
|
162
|
+
seen.observe(el.firstElementChild);
|
|
163
|
+
return () => seen.disconnect();
|
|
164
|
+
}, []);
|
|
165
|
+
return (_jsx("div", { ref: ref, "data-slot": "view-scroll", className: VIEW_SCROLL, children: _jsx("div", { className: cn('flex flex-col gap-(--shell-section-gap)', fit ? 'w-full' : 'w-max min-w-full'), children: children }) }));
|
|
166
|
+
}
|
|
167
|
+
const codeSlots = (cards, scope) => (cards ?? []).map((c) => ({
|
|
168
|
+
id: c.id,
|
|
169
|
+
w: c.w ?? GRID_COLS,
|
|
170
|
+
h: c.h,
|
|
171
|
+
wide: (c.kind === 'table' || c.kind === 'own') && (c.w ?? GRID_COLS) === GRID_COLS && !c.h,
|
|
172
|
+
locked: true,
|
|
173
|
+
node: c.kind === 'own' ? (c.frame ? (c.frame({})) : (_jsx(OwnCard, { card: c }))) : (_jsx(CodeCard, { card: { ...c, load: scope(c.id, c.load) }, inScroll: true })),
|
|
174
|
+
}));
|
|
175
|
+
/** The saved cards at the places they were moved and sized to, in reading order on a desktop. */
|
|
176
|
+
function placeCards(cards, places, phone) {
|
|
177
|
+
const at = new Map(places.map((p) => [p.id, p]));
|
|
178
|
+
const next = cards.map((c) => {
|
|
179
|
+
const p = at.get(c.id);
|
|
180
|
+
if (!p)
|
|
181
|
+
return c;
|
|
182
|
+
const h = p.h === undefined ? {} : { h: p.h };
|
|
183
|
+
if (phone)
|
|
184
|
+
return { ...c, phone: { x: p.x, y: p.y, w: p.w, ...h } };
|
|
185
|
+
const { h: _, ...rest } = c;
|
|
186
|
+
return { ...rest, x: p.x, y: p.y, w: p.w, ...h };
|
|
187
|
+
});
|
|
188
|
+
return phone
|
|
189
|
+
? next
|
|
190
|
+
: [...next].sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0));
|
|
191
|
+
}
|
|
192
|
+
/** A card that draws its own frame (a coverage map): it still follows its colour's chips. */
|
|
193
|
+
function OwnCard({ card }) {
|
|
194
|
+
const follows = useCardFollows(card);
|
|
195
|
+
return _jsx(_Fragment, { children: typeof card.children === 'function' ? card.children(follows) : card.children });
|
|
196
|
+
}
|
|
197
|
+
/** A page of code-built cards only (no saved view). */
|
|
198
|
+
function CardsLayout({ crumbs, actions, meta, status, intro, cards, lead, fit }) {
|
|
199
|
+
const scope = useCardLoads();
|
|
200
|
+
// Its cards link among themselves, from the colours they open with or the ones picked here.
|
|
201
|
+
const start = useMemo(() => codeColors([lead, cards]), [lead, cards]);
|
|
202
|
+
const { links } = useLinkState(start, useKeptColors([lead, cards]));
|
|
203
|
+
return (_jsx(Page, { className: VIEW_PAGE, crumbs: crumbs, actions: actions, meta: meta, status: status, children: _jsx(LinksProvider, { links: links, children: _jsxs(Scroller, { fit: fit, children: [intro && (_jsx("div", { "data-slot": "page-intro", className: INTRO, children: intro })), _jsx(LumenGrid, { lead: codeSlots(lead, scope), cards: codeSlots(cards, scope), editing: false })] }) }) }));
|
|
204
|
+
}
|
|
205
|
+
/** A saved view's page: its filter, Group button and cards, Edit view and Edit layout. */
|
|
206
|
+
/**
|
|
207
|
+
* A second table on a saved page (several tables on a layout): its own
|
|
208
|
+
* columns, sort and grouping over the view's rows (and formulas), its own chips and tapped row,
|
|
209
|
+
* shared with the cards of its colour like any card. A Columns change saves into its layout.
|
|
210
|
+
*/
|
|
211
|
+
function LayoutTable({ card, base, config, rows, load, state, onDetails, controls, onLayout, }) {
|
|
212
|
+
const own = useMemo(() => tableConfigOf(config, { ...card, table: card.table ?? tableLayoutOf(config) }), [config, card]);
|
|
213
|
+
const domain = useMemo(() => withViewFields(base, own), [base, own]);
|
|
214
|
+
return (_jsx(LumenViewCard, { id: card.id, domain: domain, config: own, rows: rows, load: load, state: state, title: card.title ?? config.name, onDetails: onDetails, controls: controls, shown: card.controls, onColumns: onLayout && ((columns) => onLayout(tableLayoutOf({ ...own, columns }))) }));
|
|
215
|
+
}
|
|
216
|
+
function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead, domain: base, config, rows, onConfigChange: saveConfig, editable: canEdit, editor, onDuplicate, onDelete, onReset, load, state, tile, rowTitle, viewId, onShownRows, onSummaryRows, selection, onOpen, rowPick: initialPick, groupActions, onTokens, viewTools, columnPicker, hideFilter, save: saveMode = 'document', }) {
|
|
217
|
+
const onConfigChange = canEdit === false ? undefined : saveConfig;
|
|
218
|
+
// The registry's join fields for the keys the rows carry (Symbol, Account), then the view's own
|
|
219
|
+
// formulas and formats.
|
|
220
|
+
const joins = useJoins(base, rows, config);
|
|
221
|
+
const domain = useMemo(() => withViewFields(joins.domain, config), [joins.domain, config]);
|
|
222
|
+
const scope = useCardLoads();
|
|
223
|
+
const columns = pick(domain.fields, config.columns);
|
|
224
|
+
const filterFields = useMemo(() => cardFilterFields(filterFieldsOf(domain.fields, config.filters.fields)), [domain.fields, config.filters.fields]);
|
|
225
|
+
const filter = useLumenFilter(rows, filterFields, {
|
|
226
|
+
text: domain.text,
|
|
227
|
+
initial: config.filters.default,
|
|
228
|
+
});
|
|
229
|
+
// A view patched while open (the assistant's card move, a save from another tab) applies its new saved
|
|
230
|
+
// filters at once; the person's own unsaved tokens stay while the saved ones
|
|
231
|
+
// do not change.
|
|
232
|
+
const savedFilters = JSON.stringify(config.filters.default);
|
|
233
|
+
const savedFiltersRef = useRef(savedFilters);
|
|
234
|
+
const { setTokens } = filter;
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (savedFiltersRef.current === savedFilters)
|
|
237
|
+
return;
|
|
238
|
+
savedFiltersRef.current = savedFilters;
|
|
239
|
+
setTokens(config.filters.default);
|
|
240
|
+
}, [savedFilters, config.filters.default, setTokens]);
|
|
241
|
+
useEffect(() => onTokens?.(filter.tokens), [onTokens, filter.tokens]);
|
|
242
|
+
const group = useGroup(domain, config);
|
|
243
|
+
// The sort the person set by a header click, held like the chips and grouping (rule C).
|
|
244
|
+
const [heldSort, setHeldSort] = useState(config.sort);
|
|
245
|
+
const savedSort = JSON.stringify(config.sort ?? null);
|
|
246
|
+
const savedSortRef = useRef(savedSort);
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (savedSortRef.current === savedSort)
|
|
249
|
+
return;
|
|
250
|
+
savedSortRef.current = savedSort;
|
|
251
|
+
setHeldSort(config.sort);
|
|
252
|
+
}, [savedSort, config.sort]);
|
|
253
|
+
const [details, setDetails] = useState(null);
|
|
254
|
+
const onDetails = onOpen ?? (domain.details?.length ? setDetails : undefined);
|
|
255
|
+
// The assistant sees the view, its data domain, its filter, how many rows show, the first of them and the
|
|
256
|
+
// row open in the drawer (it saw only a row count and could not answer).
|
|
257
|
+
useAssistantNote('view', {
|
|
258
|
+
label: details && rowTitle ? `${rowTitle(details)} selected` : '',
|
|
259
|
+
data: {
|
|
260
|
+
view: config.name,
|
|
261
|
+
domain: domain.id,
|
|
262
|
+
filter: filter.tokens.map((t) => `${t.not ? 'not ' : ''}${t.key}${t.op ?? ':'}${t.value}`),
|
|
263
|
+
rows: filter.results.length,
|
|
264
|
+
sources: joins.sources.map(sourceLine),
|
|
265
|
+
first_rows: filter.results
|
|
266
|
+
.slice(0, ASSISTANT_ROWS)
|
|
267
|
+
.map((r) => Object.fromEntries(columns.map((f) => [f.label, f.value(r)]))),
|
|
268
|
+
selected: details
|
|
269
|
+
? Object.fromEntries(columns.map((f) => [f.label, f.value(details)]))
|
|
270
|
+
: undefined,
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
const [editing, setEditing] = useState(false);
|
|
274
|
+
// While the layout is edited, changes go to a draft saved on Done; otherwise a card's ⋯ menu
|
|
275
|
+
// saves at once.
|
|
276
|
+
const [layout, setLayout] = useState(false);
|
|
277
|
+
const [draft, setDraft] = useState([]);
|
|
278
|
+
const [adding, setAdding] = useState(false);
|
|
279
|
+
// Any card but the view's own table, edited in the same editor.
|
|
280
|
+
const [editingWidget, setEditingWidget] = useState(null);
|
|
281
|
+
// The page's own framed cards join the layout: where the saved layout
|
|
282
|
+
// places one, it keeps its place and width; one it lacks goes last, full width; one the page no
|
|
283
|
+
// longer draws is skipped.
|
|
284
|
+
const placed = useMemo(() => (extra ?? []).filter((c) => c.frame), [extra]);
|
|
285
|
+
const saved = useMemo(() => {
|
|
286
|
+
const kept = cardsOf(config).filter((c) => c.kind !== 'page' || placed.some((p) => p.id === c.id));
|
|
287
|
+
const added = placed
|
|
288
|
+
.filter((p) => !kept.some((c) => c.id === p.id))
|
|
289
|
+
.map((p) => ({
|
|
290
|
+
id: p.id,
|
|
291
|
+
kind: 'page',
|
|
292
|
+
title: null,
|
|
293
|
+
w: p.w ?? GRID_COLS,
|
|
294
|
+
filter: [],
|
|
295
|
+
}));
|
|
296
|
+
return [...kept, ...added];
|
|
297
|
+
}, [config, placed]);
|
|
298
|
+
const cards = layout ? draft : saved;
|
|
299
|
+
// Filter links: each card's colour for the visit, and the linked chips of
|
|
300
|
+
// the view's filter and of each widget's own filter (a page's second tables publish theirs).
|
|
301
|
+
const savedLinks = useMemo(() => ({
|
|
302
|
+
...codeColors([lead, extra]),
|
|
303
|
+
...Object.fromEntries(saved.map((c) => [c.id, c.link ?? null])),
|
|
304
|
+
}), [saved, lead, extra]);
|
|
305
|
+
const { links, colors } = useLinkState(savedLinks, useKeptColors([lead, extra]));
|
|
306
|
+
// The page's main table draws the view's own layout and holds its chips, sort and grouping;
|
|
307
|
+
// every other table draws its own (several tables on a layout).
|
|
308
|
+
const mainTable = mainTableOf(saved);
|
|
309
|
+
const viewCardId = mainTable?.id ?? 'view';
|
|
310
|
+
const viewColor = colors[viewCardId] ?? null;
|
|
311
|
+
const { publish } = links;
|
|
312
|
+
// A row tap in the linked table narrows the cards of its colour.
|
|
313
|
+
const rowPick = useRowPick(domain, viewColor, initialPick);
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
publish(viewCardId, shared([...filter.tokens, ...rowPick.token], viewColor));
|
|
316
|
+
}, [publish, viewCardId, viewColor, filter.tokens, rowPick.token]);
|
|
317
|
+
// A widget's own filter is its own: only the table's chips and tapped row
|
|
318
|
+
// (and a page's second tables, which publish theirs) are shared with the colour group.
|
|
319
|
+
const widgetFields = useMemo(() => [...cardFilterFields(domain.fields), ...keyFilterFields(domain.keys)], [domain.fields, domain.keys]);
|
|
320
|
+
const followed = (id) => followedBy(links.follows(id), widgetFields, domain.keys);
|
|
321
|
+
const narrow = (rs, ts) => ts.length ? filterByTokens(rs, widgetFields, ts, domain.text) : rs;
|
|
322
|
+
const viewFollows = followed(viewCardId);
|
|
323
|
+
const viewFollowKey = JSON.stringify(viewFollows);
|
|
324
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on what the table follows.
|
|
325
|
+
const viewRows = useMemo(() => narrow(filter.results, viewFollows), [filter.results, viewFollowKey, widgetFields]);
|
|
326
|
+
useEffect(() => onShownRows?.(viewRows), [onShownRows, viewRows]);
|
|
327
|
+
const linksChanged = saved.some((c) => (colors[c.id] ?? null) !== (c.link ?? null));
|
|
328
|
+
const cardLink = (id, follows, linked) => ({
|
|
329
|
+
color: colors[id] ?? null,
|
|
330
|
+
onPick: (color) => links.setColor(id, color),
|
|
331
|
+
follows: followsText(follows),
|
|
332
|
+
linked,
|
|
333
|
+
});
|
|
334
|
+
const commit = (next) => layout ? setDraft(next) : onConfigChange?.({ ...config, cards: next });
|
|
335
|
+
const putCard = (c) => {
|
|
336
|
+
const i = cards.findIndex((x) => x.id === c.id);
|
|
337
|
+
commit(i < 0 ? [...cards, c] : cards.map((x, j) => (j === i ? c : x)));
|
|
338
|
+
};
|
|
339
|
+
const act = (i, a) => {
|
|
340
|
+
const c = cards[i];
|
|
341
|
+
if (!c)
|
|
342
|
+
return;
|
|
343
|
+
if (a === 'edit')
|
|
344
|
+
return isTableCard(c) && c.id === viewCardId
|
|
345
|
+
? setEditing(true)
|
|
346
|
+
: c.kind !== 'page' && setEditingWidget(c);
|
|
347
|
+
// A copy of the page's first Summary takes its own copy of the view's cards, and a copy of
|
|
348
|
+
// the main table its own copy of the view's layout.
|
|
349
|
+
if (a === 'duplicate' && ((isRowCard(c) && !c.tiles) || (isTableCard(c) && !c.table))) {
|
|
350
|
+
const next = cardsAfter(cards, i, a);
|
|
351
|
+
const copy = next?.[i + 1];
|
|
352
|
+
if (next && copy)
|
|
353
|
+
commit(next.map((x, j) => j !== i + 1
|
|
354
|
+
? x
|
|
355
|
+
: isRowCard(c)
|
|
356
|
+
? { ...copy, tiles: tilesOfCard(config, c) }
|
|
357
|
+
: { ...copy, table: tableLayoutOf(config) }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const next = cardsAfter(cards, i, a);
|
|
361
|
+
if (next)
|
|
362
|
+
commit(next);
|
|
363
|
+
};
|
|
364
|
+
const mergeFlat = useCallback((rs) => mergeRows(domain, rs, null), [domain]);
|
|
365
|
+
const { pivot } = group;
|
|
366
|
+
const mobile = useIsMobile();
|
|
367
|
+
// A phone has no width to fit to: the table scrolls sideways there.
|
|
368
|
+
const fit = config.width === 'fit' && !pivot && !mobile;
|
|
369
|
+
// Each Summary's cards: the domain's, drawn by the app, and the view's
|
|
370
|
+
// own, worked out over the rows the Summary shows (every row, narrowed by its own filter; the
|
|
371
|
+
// table's filter is the table's).
|
|
372
|
+
// Each Summary counts the domain's cash rows only when it says so.
|
|
373
|
+
const summaryRows = (c) => {
|
|
374
|
+
const follows = followed(c.id);
|
|
375
|
+
return {
|
|
376
|
+
rows: narrow(c.filter.length ? filterByTokens(rows, widgetFields, c.filter) : rows, follows),
|
|
377
|
+
narrowed: c.filter.length > 0 || follows.length > 0,
|
|
378
|
+
};
|
|
379
|
+
};
|
|
380
|
+
const summaryAll = cards.flatMap((c) => (isRowCard(c) ? summaryRows(c).rows : []));
|
|
381
|
+
const summaryKey = [...new Set(summaryAll.map(domain.rowKey))].sort().join('\n');
|
|
382
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on which rows the Summaries draw.
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (onSummaryRows)
|
|
385
|
+
onSummaryRows([...new Set(summaryAll)]);
|
|
386
|
+
}, [onSummaryRows, summaryKey]);
|
|
387
|
+
const summaryOf = (c) => {
|
|
388
|
+
const list = tilesOfCard(config, c);
|
|
389
|
+
const { rows: drawn, narrowed } = summaryRows(c);
|
|
390
|
+
const cash = domain.cash;
|
|
391
|
+
const own = cash && !summaryCash(c, narrowed) ? drawn.filter((r) => !cash(r)) : drawn;
|
|
392
|
+
return state ? null : (_jsx(SummaryTiles, { domain: domain, tiles: list, rows: own, fields: widgetFields, tile: tile, loading: !!load?.loading }));
|
|
393
|
+
};
|
|
394
|
+
// A LumenCard: one number over the rows narrowed by its filter and colour.
|
|
395
|
+
const cardOf = (c) => {
|
|
396
|
+
const follows = followed(c.id);
|
|
397
|
+
const own = narrow(c.filter.length ? filterByTokens(rows, widgetFields, c.filter) : rows, follows);
|
|
398
|
+
const cash = domain.cash;
|
|
399
|
+
const drawn = cash && !summaryCash({}, c.filter.length > 0 || follows.length > 0)
|
|
400
|
+
? own.filter((r) => !cash(r))
|
|
401
|
+
: own;
|
|
402
|
+
return state ? null : (_jsx(LumenCard, { domain: domain, tile: c.tile, rows: drawn, fields: widgetFields, drawn: tile, loading: !!load?.loading, style: c.style, target: c.target, meter: c.meter, bands: c.bands }));
|
|
403
|
+
};
|
|
404
|
+
const hasCards = (c) => tilesOfCard(config, c).some((t) => isOwnTile(t) || tile);
|
|
405
|
+
// The filter and Group sit in the view's card. A chip narrows that table
|
|
406
|
+
// only, unless it carries the card's colour square: then every card of that colour follows it.
|
|
407
|
+
// Its save icon also saves the page's colours.
|
|
408
|
+
// What the person holds now against what is saved (rule C): their chips
|
|
409
|
+
// (with the page's colours), sort and grouping as one config. A sheet (workspace tab) saves it
|
|
410
|
+
// on every change; a document (saved view) shows it as Changed with Save view and Revert.
|
|
411
|
+
const heldTokens = filter.tokens;
|
|
412
|
+
const heldConfig = useMemo(() => {
|
|
413
|
+
const tokKey = (ts) => ts
|
|
414
|
+
.map((t) => JSON.stringify([t.key, t.op ?? null, t.value, !!t.not, !!t.link]))
|
|
415
|
+
.sort()
|
|
416
|
+
.join('\n');
|
|
417
|
+
const sameChips = tokKey(heldTokens) === tokKey(config.filters.default);
|
|
418
|
+
const filters = sameChips
|
|
419
|
+
? config.filters
|
|
420
|
+
: withSavedFilters(domain.fields, config.filters, heldTokens.map(({ link, ...t }) => (link && viewColor ? { ...t, link: viewColor } : t)));
|
|
421
|
+
const savedSub = subgroupOf(domain, config.subgroup)?.key ?? null;
|
|
422
|
+
const { pivot: heldPivot, group: heldGroup, subgroup: heldSub } = group.held;
|
|
423
|
+
const shape = heldPivot ? 'pivot' : config.shape === 'pivot' ? 'table' : config.shape;
|
|
424
|
+
return {
|
|
425
|
+
...config,
|
|
426
|
+
filters,
|
|
427
|
+
sort: heldSort,
|
|
428
|
+
shape,
|
|
429
|
+
group: heldPivot ? config.group : { ...config.group, default: heldGroup },
|
|
430
|
+
...(heldSub !== savedSub ? { subgroup: heldSub ?? undefined } : {}),
|
|
431
|
+
...(linksChanged
|
|
432
|
+
? {
|
|
433
|
+
cards: saved.map((c) => {
|
|
434
|
+
const { link: _, ...rest } = c;
|
|
435
|
+
const color = colors[c.id];
|
|
436
|
+
return (color ? { ...rest, link: color } : rest);
|
|
437
|
+
}),
|
|
438
|
+
}
|
|
439
|
+
: {}),
|
|
440
|
+
};
|
|
441
|
+
}, [config, heldTokens, heldSort, group.held, domain, viewColor, linksChanged, saved, colors]);
|
|
442
|
+
const heldJson = JSON.stringify(heldConfig);
|
|
443
|
+
const configJson = JSON.stringify(config);
|
|
444
|
+
const changed = !!onConfigChange && heldJson !== configJson;
|
|
445
|
+
// A sheet saves what changed as it changes; each config once, so a save that comes back the
|
|
446
|
+
// same never loops. When the saved config changes from outside (the assistant patch, another tab),
|
|
447
|
+
// the held chips, sort and grouping still show the old one for a render: that render saves
|
|
448
|
+
// nothing, or it would write the old layout back over the new one.
|
|
449
|
+
const sentRef = useRef(null);
|
|
450
|
+
const seenRef = useRef(configJson);
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
if (seenRef.current !== configJson) {
|
|
453
|
+
seenRef.current = configJson;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (saveMode !== 'sheet' || !changed || sentRef.current === heldJson)
|
|
457
|
+
return;
|
|
458
|
+
sentRef.current = heldJson;
|
|
459
|
+
onConfigChange?.(heldConfig);
|
|
460
|
+
}, [saveMode, changed, heldJson, heldConfig, onConfigChange, configJson]);
|
|
461
|
+
const revert = () => {
|
|
462
|
+
setTokens(config.filters.default);
|
|
463
|
+
setHeldSort(config.sort);
|
|
464
|
+
group.reset();
|
|
465
|
+
};
|
|
466
|
+
// + Add column: Field is the Columns picker at the +, Formula opens the
|
|
467
|
+
// formula editor here, Your data makes the person's own column (a linked frame of theirs,
|
|
468
|
+
// attached to this card). Ask the assistant is the card's ⋯ menu, on every card.
|
|
469
|
+
const assistant = useAssistant();
|
|
470
|
+
const yours = useYourData(assistant?.api ?? null);
|
|
471
|
+
const [addingFormula, setAddingFormula] = useState(false);
|
|
472
|
+
const [addingYours, setAddingYours] = useState(false);
|
|
473
|
+
const yoursKey = domain.keys?.symbol ? 'symbol' : domain.keys?.account ? 'account' : null;
|
|
474
|
+
const addColumn = onConfigChange
|
|
475
|
+
? {
|
|
476
|
+
formula: takesFormulas(base)
|
|
477
|
+
? () => setAddingFormula(true)
|
|
478
|
+
: { disabled: 'This card’s data takes no formula columns' },
|
|
479
|
+
yours: !assistant
|
|
480
|
+
? { disabled: 'Your data needs an assistant on this app' }
|
|
481
|
+
: !yoursKey
|
|
482
|
+
? { disabled: 'This card’s rows carry no symbol or account to key your column by' }
|
|
483
|
+
: () => setAddingYours(true),
|
|
484
|
+
}
|
|
485
|
+
: undefined;
|
|
486
|
+
// The table's controls (Edit widget ▸ Controls): its Filter box and Group.
|
|
487
|
+
const viewCard = mainTableOf(cards);
|
|
488
|
+
const viewOn = (k) => !viewCard || controlOn(viewCard, k);
|
|
489
|
+
const showBox = !hideFilter && viewOn('filter');
|
|
490
|
+
const groupButton = viewOn('group') ? group.button : null;
|
|
491
|
+
const viewFilter = !showBox && !groupButton ? undefined : (_jsxs(_Fragment, { children: [showBox && (_jsx(LumenTokenizedFilter, { filter: filter, label: `Filter ${config.name}`, className: "min-w-0 flex-1", saved: config.filters.default, link: {
|
|
492
|
+
color: viewColor,
|
|
493
|
+
onPick: (color) => links.setColor(viewCardId, color),
|
|
494
|
+
} })), groupButton] }));
|
|
495
|
+
// Link (connections): the offer adds the connection and a few of its fields.
|
|
496
|
+
const connect = (c) => onConfigChange?.({
|
|
497
|
+
...config,
|
|
498
|
+
connections: [...(config.connections ?? []), c],
|
|
499
|
+
columns: [
|
|
500
|
+
...(config.columns.length ? config.columns : columns.map((f) => f.key)),
|
|
501
|
+
...(linkColumns(c.to).length ? linkColumns(c.to) : [CONNECTED_COUNT]).map((k) => connectedKey(c.to, k)),
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
const editable = !!onConfigChange;
|
|
505
|
+
// A Summary with no cards shows nothing until the layout is edited.
|
|
506
|
+
const shown = layout ? cards : cards.filter((c) => !isRowCard(c) || (!state && hasCards(c)));
|
|
507
|
+
const gridCards = shown.map((c) => {
|
|
508
|
+
const i = cards.indexOf(c);
|
|
509
|
+
const place = { id: c.id, w: c.w, h: c.h, x: c.x, y: c.y, phone: c.phone };
|
|
510
|
+
const controls = editable
|
|
511
|
+
? {
|
|
512
|
+
layout,
|
|
513
|
+
editable,
|
|
514
|
+
first: i === 0,
|
|
515
|
+
last: i === cards.length - 1,
|
|
516
|
+
onFit: c.h !== undefined || c.phone?.h !== undefined
|
|
517
|
+
? () => {
|
|
518
|
+
const { h: _, ...rest } = c;
|
|
519
|
+
const { h: _p, ...phone } = c.phone ?? { x: 0, y: 0, w: 1 };
|
|
520
|
+
putCard({ ...rest, ...(c.phone ? { phone } : {}) });
|
|
521
|
+
}
|
|
522
|
+
: undefined,
|
|
523
|
+
onAction: (a) => act(i, a),
|
|
524
|
+
fixed: c.kind === 'page',
|
|
525
|
+
}
|
|
526
|
+
: undefined;
|
|
527
|
+
if (c.kind === 'page') {
|
|
528
|
+
const p = placed.find((x) => x.id === c.id);
|
|
529
|
+
return {
|
|
530
|
+
...place,
|
|
531
|
+
wide: c.w === GRID_COLS && c.h === undefined,
|
|
532
|
+
node: p?.frame?.({ controls }) ?? null,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
...place,
|
|
537
|
+
wide: isTableCard(c) && c.w === GRID_COLS && c.h === undefined,
|
|
538
|
+
node: c.source ? (_jsx(SourcedCard, { card: c, name: config.name, controls: controls })) : isTableCard(c) && c.id !== viewCardId ? (_jsx(LayoutTable, { card: c, base: joins.domain, config: config, rows: c.filter.length ? filterByTokens(rows, widgetFields, c.filter) : rows, load: scope(c.id, load), state: state, onDetails: onDetails, controls: controls, onLayout: onConfigChange ? (table) => putCard({ ...c, table }) : undefined })) : isTableCard(c) ? (_jsx(LumenView, { domain: domain, config: config, rows: viewRows, total: rows.length, groupBy: group.groupField, pivot: pivot, subgroup: group.sub, load: scope(c.id, load), state: state, title: c.title ?? config.name, selection: selection, onDetails: rowPick.onTap ?? onDetails, tapped: rowPick.tapped, groupActions: groupActions, controls: controls, inLayout: true, tools: viewTools, filter: viewFilter, filterCount: filter.tokens.length, strip: _jsx(ConnectOffer, { domain: domain, rows: rows, config: config, read: joins.read, cardKey: viewId ?? config.name, onConnect: onConfigChange ? connect : undefined }), link: cardLink(c.id, viewFollows, viewColor ? filter.tokens.length : 0), shown: c.controls, onColumns: onConfigChange && columnPicker !== false
|
|
539
|
+
? (columns) => onConfigChange({ ...config, columns })
|
|
540
|
+
: undefined, onAddColumn: addColumn, sort: heldSort, onSort: setHeldSort })) : (_jsx(LumenSavedWidget, { widget: c, title: cardTitle(c, config.name), domain: domain, rows: narrow(rows, followed(c.id)), link: cardLink(c.id, followed(c.id)), fields: widgetFields, merge: mergeFlat, onDetails: onDetails, load: scope(c.id, load), tiles: 'tile' in c
|
|
541
|
+
? cardOf(c)
|
|
542
|
+
: isRowCard(c)
|
|
543
|
+
? (summaryOf(c) ?? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No cards yet: add them in Edit widget." })))
|
|
544
|
+
: null, controls: controls })),
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
// The assistant opens a row's drawer by its title (the first that matches: whole, then start, then
|
|
548
|
+
// part); Undo puts back the row that was open. Mid-edit, its moves wait for the person's tap.
|
|
549
|
+
const titleOf = (r) => rowTitle?.(r) ?? domain.rowKey(r);
|
|
550
|
+
useAssistantMoves('view', {
|
|
551
|
+
open: (q) => {
|
|
552
|
+
if (!onDetails)
|
|
553
|
+
return 'Rows here have no details to open';
|
|
554
|
+
const want = q.trim().toLowerCase();
|
|
555
|
+
const pool = [...filter.results, ...rows];
|
|
556
|
+
const t = (r) => titleOf(r).toLowerCase();
|
|
557
|
+
const row = pool.find((r) => t(r) === want) ??
|
|
558
|
+
pool.find((r) => t(r).startsWith(want)) ??
|
|
559
|
+
pool.find((r) => t(r).includes(want));
|
|
560
|
+
if (!row)
|
|
561
|
+
return `no row "${q}" in ${config.name}`;
|
|
562
|
+
onDetails(row);
|
|
563
|
+
return null;
|
|
564
|
+
},
|
|
565
|
+
rows: () => filter.results.slice(0, 30).map(titleOf),
|
|
566
|
+
// The read-back after a card move: rows shown, columns empty on every shown row, the sources.
|
|
567
|
+
read: () => {
|
|
568
|
+
if (load?.loading)
|
|
569
|
+
return '';
|
|
570
|
+
const shown = filter.results;
|
|
571
|
+
// Values as the table works them out: a formula's own `value` is always null (every formula column read back to the assistant as empty, whatever the page showed).
|
|
572
|
+
const computed = computedOf(domain, shown, null);
|
|
573
|
+
const blanks = formulaBlanks(domain, shown);
|
|
574
|
+
const empty = columns
|
|
575
|
+
.filter((f) => !blanks.has(f.key) &&
|
|
576
|
+
shown.length &&
|
|
577
|
+
shown.every((r) => {
|
|
578
|
+
const v = computed.value(f, r);
|
|
579
|
+
return v === null || v === undefined || v === '';
|
|
580
|
+
}))
|
|
581
|
+
.map((f) => f.key);
|
|
582
|
+
const failing = columns
|
|
583
|
+
.filter((f) => blanks.has(f.key))
|
|
584
|
+
.map((f) => `${f.key} ${blanks.get(f.key)}`);
|
|
585
|
+
return `${config.name}: ${shown.length} rows${rows.length !== shown.length ? ` of ${rows.length}` : ''}; columns ${columns.map((f) => f.key).join(', ')}${empty.length ? `; empty in every row: ${empty.join(', ')}` : ''}${failing.length ? `; formulas blank: ${failing.join('; ')}` : ''}${joins.sources.length ? `; sources ${joins.sources.map(sourceLine).join(', ')}` : ''}`;
|
|
586
|
+
},
|
|
587
|
+
editing: () => layout || editing || adding || editingWidget !== null,
|
|
588
|
+
snapshot: () => (details === null ? null : domain.rowKey(details)),
|
|
589
|
+
restore: (k) => setDetails(k === null ? null : (rows.find((r) => domain.rowKey(r) === k) ?? null)),
|
|
590
|
+
});
|
|
591
|
+
// The open row as the rows are now, so an edit made in the drawer shows in it.
|
|
592
|
+
const key = details === null ? null : domain.rowKey(details);
|
|
593
|
+
const open = key === null ? null : (rows.find((r) => domain.rowKey(r) === key) ?? details);
|
|
594
|
+
const openTitle = open === null ? '' : (rowTitle?.(open) ?? domain.rowKey(open));
|
|
595
|
+
const body = (_jsxs(_Fragment, { children: [intro && (_jsx("div", { "data-slot": "page-intro", className: INTRO, children: intro })), _jsx(LumenGrid, { lead: codeSlots(lead, scope), cards: gridCards, tail: codeSlots(extra?.filter((c) => !c.frame), scope), editing: layout, onPlace: (places, phone) => setDraft((d) => placeCards(d, places, phone)) }), layout && (_jsx(IconButton, { icon: PlusIcon, label: "Add widget", variant: "outline", size: "icon", className: "sticky left-0 h-16 w-(--view-w) border-2 border-dashed text-muted-foreground", onClick: () => setAdding(true) }))] }));
|
|
596
|
+
return (_jsxs(Page, { className: VIEW_PAGE, crumbs: crumbs, meta: layout ? _jsx(LayoutChip, {}) : meta, status: _jsxs(_Fragment, { children: [status, _jsx(OriginLegend, { domain: domain, columns: columns }), _jsx(SourceStatusItems, { sources: joins.sources, actions: joins.actions })] }), actions: actions, layout: layout ? (_jsx(LayoutActions, { onCancel: () => {
|
|
597
|
+
setDraft(saved);
|
|
598
|
+
setLayout(false);
|
|
599
|
+
}, onAdd: () => setAdding(true), onReset: onConfigChange &&
|
|
600
|
+
onReset &&
|
|
601
|
+
(() => {
|
|
602
|
+
setLayout(false);
|
|
603
|
+
onReset();
|
|
604
|
+
}), onSave: () => {
|
|
605
|
+
setLayout(false);
|
|
606
|
+
if (JSON.stringify(draft) !== JSON.stringify(saved))
|
|
607
|
+
onConfigChange?.({ ...config, cards: draft });
|
|
608
|
+
} })) : (onConfigChange && (_jsxs(_Fragment, { children: [changed && saveMode === 'document' && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: Undo2Icon, label: "Revert view", onClick: revert }), _jsx(IconButton, { icon: SaveIcon, label: "Save view (chips, sort, group)", variant: "default", onClick: () => onConfigChange?.(heldConfig) })] })), _jsx(IconButton, { icon: LayoutDashboardIcon, label: "Edit layout", onClick: () => {
|
|
609
|
+
setDraft(saved);
|
|
610
|
+
setLayout(true);
|
|
611
|
+
} })] }))), children: [_jsx(LinksProvider, { links: links, children: _jsx(Scroller, { fit: fit, children: body }) }), _jsx(LumenDrawer, { open: open !== null, title: openTitle, onClose: () => setDetails(null), children: open !== null && (_jsx(DetailsBody, { domain: domain, row: open, columns: columns, chips: config.chips ?? [] })) }), onConfigChange && (_jsxs(_Fragment, { children: [_jsx(AddWidgetDialog, { open: adding, onOpenChange: setAdding, domain: domain, cards: cards, name: config.name, onAdd: (c) => {
|
|
612
|
+
setAdding(false);
|
|
613
|
+
// A second table starts as a copy of the view's layout, then goes its own way.
|
|
614
|
+
putCard(isTableCard(c) && mainTable && !c.source
|
|
615
|
+
? { ...c, table: tableLayoutOf(config) }
|
|
616
|
+
: c);
|
|
617
|
+
}, onBuild: (c) => {
|
|
618
|
+
setAdding(false);
|
|
619
|
+
if (c.kind !== 'page')
|
|
620
|
+
setEditingWidget(c);
|
|
621
|
+
} }), editingWidget && (_jsx(ViewEditDialog, { open: true, onOpenChange: (o) => !o && setEditingWidget(null), domain: joins.domain, config: config, rows: rows, current: [], card: editingWidget, fields: widgetFields, viewTiles: config.tiles, read: joins.read, onSaveCard: (c, view) => {
|
|
622
|
+
setEditingWidget(null);
|
|
623
|
+
const i = cards.findIndex((x) => x.id === c.id);
|
|
624
|
+
const next = i < 0 ? [...cards, c] : cards.map((x, j) => (j === i ? c : x));
|
|
625
|
+
if (layout) {
|
|
626
|
+
setDraft(next);
|
|
627
|
+
onConfigChange({ ...config, ...view });
|
|
628
|
+
}
|
|
629
|
+
else
|
|
630
|
+
onConfigChange({ ...config, ...view, cards: next });
|
|
631
|
+
}, editor: editor })), _jsx(AddFormulaDialog, { open: addingFormula, onOpenChange: setAddingFormula, domain: joins.domain, formulas: config.formulas, onAdd: (f) => {
|
|
632
|
+
setAddingFormula(false);
|
|
633
|
+
onConfigChange({
|
|
634
|
+
...config,
|
|
635
|
+
formulas: [...config.formulas, f],
|
|
636
|
+
// A view still on its default columns keeps them beside the new one.
|
|
637
|
+
columns: [
|
|
638
|
+
...(config.columns.length ? config.columns : columns.map((c) => c.key)),
|
|
639
|
+
f.key,
|
|
640
|
+
],
|
|
641
|
+
});
|
|
642
|
+
} }), _jsx(YourDataDialog, { open: addingYours, onOpenChange: (o) => {
|
|
643
|
+
setAddingYours(o);
|
|
644
|
+
if (!o)
|
|
645
|
+
yours.reset();
|
|
646
|
+
}, keyLabel: yoursKey ?? 'row', busy: yours.isPending, error: yours.error?.message ?? null, onAdd: (column) => {
|
|
647
|
+
if (!yoursKey)
|
|
648
|
+
return;
|
|
649
|
+
yours.mutate({ key: yoursKey, column }, {
|
|
650
|
+
onSuccess: (meta) => {
|
|
651
|
+
setAddingYours(false);
|
|
652
|
+
const added = meta.columns.find((c) => c.label === column.label)?.key;
|
|
653
|
+
onConfigChange({
|
|
654
|
+
...config,
|
|
655
|
+
frames: [...(config.frames ?? []).filter((id) => id !== meta.id), meta.id],
|
|
656
|
+
columns: added ? [...config.columns, added] : config.columns,
|
|
657
|
+
});
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
} }), _jsx(ViewEditDialog, { open: editing, onOpenChange: setEditing, domain: joins.domain, config: config, rows: filter.results, current: filter.tokens, onSave: onConfigChange, read: joins.read, viewId: viewId, editor: editor, onDuplicate: onDuplicate, onDelete: onDelete })] }))] }));
|
|
661
|
+
}
|