@crossworks/share-ui 0.231.0 → 0.232.3
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/package.json +3 -3
- package/src/table-presenter.tsx +375 -101
- package/src/view-payload.ts +23 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/share-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.232.3",
|
|
4
4
|
"description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./app-presenter": "./src/app-presenter.tsx",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"./styles/app.css": "./styles/app.css"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@mantle/client-types": "npm:@crossworks/client-types@0.
|
|
36
|
-
"@mantle/content-core": "npm:@crossworks/content-core@0.
|
|
35
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.232.3",
|
|
36
|
+
"@mantle/content-core": "npm:@crossworks/content-core@0.232.3",
|
|
37
37
|
"@radix-ui/react-label": "^2.1.12",
|
|
38
38
|
"@radix-ui/react-slot": "^1.3.0",
|
|
39
39
|
"class-variance-authority": "^0.7.1",
|
package/src/table-presenter.tsx
CHANGED
|
@@ -1,25 +1,102 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
ArrowUpRight,
|
|
6
|
+
Calendar,
|
|
7
|
+
CalendarClock,
|
|
8
|
+
DollarSign,
|
|
9
|
+
Hash,
|
|
10
|
+
Link as LinkIcon,
|
|
11
|
+
List,
|
|
12
|
+
Loader2,
|
|
13
|
+
Percent,
|
|
14
|
+
SquareCheck,
|
|
15
|
+
Tags,
|
|
16
|
+
Type,
|
|
17
|
+
Variable,
|
|
18
|
+
type LucideIcon,
|
|
19
|
+
} from 'lucide-react';
|
|
20
|
+
import { AGGREGATE_KINDS, type AggregateKind } from '@mantle/content-core/table-model';
|
|
6
21
|
import { cn } from './lib/utils';
|
|
7
22
|
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
8
23
|
|
|
9
24
|
type CellValue = string | number | boolean | string[] | null;
|
|
10
25
|
type PublicRow = { id: string; cells: Record<string, CellValue> };
|
|
11
26
|
type PublicColumn = { id: string; name: string; type: string };
|
|
12
|
-
type PublicTab = {
|
|
27
|
+
type PublicTab = {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
rowCount: number;
|
|
31
|
+
columns: PublicColumn[];
|
|
32
|
+
aggregates?: Record<string, AggregateKind>;
|
|
33
|
+
aggregateValues?: Record<string, number | null>;
|
|
34
|
+
};
|
|
13
35
|
|
|
14
36
|
const PAGE = 200;
|
|
15
37
|
|
|
38
|
+
/** Header glyphs, the owner grid's map (table-grid.tsx) — the two surfaces
|
|
39
|
+
* should not disagree about what a currency column looks like. */
|
|
40
|
+
const TYPE_ICON: Record<string, LucideIcon> = {
|
|
41
|
+
text: Type,
|
|
42
|
+
number: Hash,
|
|
43
|
+
currency: DollarSign,
|
|
44
|
+
percent: Percent,
|
|
45
|
+
date: Calendar,
|
|
46
|
+
datetime: CalendarClock,
|
|
47
|
+
checkbox: SquareCheck,
|
|
48
|
+
select: List,
|
|
49
|
+
multiselect: Tags,
|
|
50
|
+
url: LinkIcon,
|
|
51
|
+
formula: Variable,
|
|
52
|
+
reference: ArrowUpRight,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const AGG_LABEL: Record<AggregateKind, string> = {
|
|
56
|
+
none: 'None',
|
|
57
|
+
sum: 'Sum',
|
|
58
|
+
avg: 'Average',
|
|
59
|
+
count: 'Count',
|
|
60
|
+
min: 'Min',
|
|
61
|
+
max: 'Max',
|
|
62
|
+
empty: 'Empty',
|
|
63
|
+
filled: 'Filled',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const NUMERIC_TYPES = new Set(['number', 'currency', 'percent', 'formula']);
|
|
67
|
+
|
|
68
|
+
/** Aggregate values are plain numbers by the time they reach here. An average
|
|
69
|
+
* keeps two decimals; everything else renders as a grouped integer-ish number,
|
|
70
|
+
* which is what a total of a currency or a count wants. */
|
|
71
|
+
function formatAggregate(value: number, kind: AggregateKind): string {
|
|
72
|
+
if (kind === 'avg') return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
73
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
74
|
+
}
|
|
75
|
+
|
|
16
76
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
77
|
+
* Read-only grid for a shared table.
|
|
78
|
+
*
|
|
79
|
+
* File-backed workbooks page their PUBLISHED rows through GET /s/[token]/rows;
|
|
80
|
+
* legacy JSONB tables arrive whole in the share view and render inline. Raw
|
|
81
|
+
* fetch on purpose: apiFetch is the app shell's authenticated wrapper. No edit
|
|
82
|
+
* affordances of any kind — the server surface is read-only anyway; this
|
|
83
|
+
* component simply has nothing to strip.
|
|
84
|
+
*
|
|
85
|
+
* ── Totals, and the one thing worth being careful about ────────────────────
|
|
86
|
+
*
|
|
87
|
+
* A footer total must never be computed from the rows this component happens to
|
|
88
|
+
* be holding. It pages 200 at a time, so a client-side sum over a partial
|
|
89
|
+
* window is not a smaller number — it is a WRONG one that looks exactly as
|
|
90
|
+
* authoritative as a right one. So for a file-backed tab every total comes from
|
|
91
|
+
* the server: the owner's presets ride in the share view already computed, and
|
|
92
|
+
* a total the READER picks is fetched from /s/[token]/aggregate, which runs it
|
|
93
|
+
* in SQL over every row.
|
|
94
|
+
*
|
|
95
|
+
* A legacy table is the exception, and only because it genuinely arrives whole:
|
|
96
|
+
* there, `computeAggregate` over the doc in hand is the complete answer.
|
|
97
|
+
*
|
|
98
|
+
* A reader's own choice is view-local and never persisted. Nothing here writes;
|
|
99
|
+
* reloading restores whatever the owner set.
|
|
23
100
|
*/
|
|
24
101
|
export function TablePresenter({
|
|
25
102
|
view,
|
|
@@ -30,7 +107,11 @@ export function TablePresenter({
|
|
|
30
107
|
title: string;
|
|
31
108
|
icon: string | null;
|
|
32
109
|
tabs: PublicTab[] | null;
|
|
33
|
-
legacyDoc: {
|
|
110
|
+
legacyDoc: {
|
|
111
|
+
columns: PublicColumn[];
|
|
112
|
+
rows: PublicRow[];
|
|
113
|
+
aggregates?: Record<string, AggregateKind>;
|
|
114
|
+
} | null;
|
|
34
115
|
};
|
|
35
116
|
token: string;
|
|
36
117
|
chrome?: PresenterChrome;
|
|
@@ -49,6 +130,12 @@ export function TablePresenter({
|
|
|
49
130
|
// Tab-switch race guard: a stale page for the previous tab must not land.
|
|
50
131
|
const reqSeq = useRef(0);
|
|
51
132
|
|
|
133
|
+
/** The reader's own picks, per column — view-local, never sent anywhere that
|
|
134
|
+
* writes. Keyed per tab so switching tabs does not carry a choice across to
|
|
135
|
+
* a column that merely shares an id position. */
|
|
136
|
+
const [readerAggs, setReaderAggs] = useState<Record<string, AggregateKind>>({});
|
|
137
|
+
const [readerVals, setReaderVals] = useState<Record<string, number | null>>({});
|
|
138
|
+
|
|
52
139
|
const fetchPage = useCallback(
|
|
53
140
|
async (offset: number, forTab: string) => {
|
|
54
141
|
const seq = ++reqSeq.current;
|
|
@@ -77,6 +164,8 @@ export function TablePresenter({
|
|
|
77
164
|
if (tab) {
|
|
78
165
|
setRows([]);
|
|
79
166
|
setTotal(tab.rowCount);
|
|
167
|
+
setReaderAggs({});
|
|
168
|
+
setReaderVals({});
|
|
80
169
|
void fetchPage(0, tab.id);
|
|
81
170
|
}
|
|
82
171
|
// Deliberately narrower than exhaustive-deps: refetch when the SELECTED TAB
|
|
@@ -98,117 +187,302 @@ export function TablePresenter({
|
|
|
98
187
|
const hasMore = !view.legacyDoc && shownRows.length < totalRows;
|
|
99
188
|
|
|
100
189
|
const numericCols = useMemo(
|
|
101
|
-
() =>
|
|
102
|
-
new Set(columns.filter((c) => c.type === 'number' || c.type === 'currency').map((c) => c.id)),
|
|
190
|
+
() => new Set(columns.filter((c) => NUMERIC_TYPES.has(c.type)).map((c) => c.id)),
|
|
103
191
|
[columns],
|
|
104
192
|
);
|
|
105
193
|
|
|
194
|
+
/** The owner's presets for whatever is on screen. */
|
|
195
|
+
const ownerAggs = view.legacyDoc ? (view.legacyDoc.aggregates ?? {}) : (tab?.aggregates ?? {});
|
|
196
|
+
|
|
197
|
+
const kindFor = (colId: string): AggregateKind => readerAggs[colId] ?? ownerAggs[colId] ?? 'none';
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The total to draw under a column, or null for "nothing to show".
|
|
201
|
+
*
|
|
202
|
+
* Three sources, and which one applies is entirely about whether the rows in
|
|
203
|
+
* hand are the whole story:
|
|
204
|
+
* - legacy → compute locally; the doc IS complete.
|
|
205
|
+
* - a reader's pick on a file-backed tab → the value fetched for it.
|
|
206
|
+
* - otherwise → the owner's preset, computed server-side in the share view.
|
|
207
|
+
*/
|
|
208
|
+
const valueFor = (colId: string): number | null => {
|
|
209
|
+
const kind = kindFor(colId);
|
|
210
|
+
if (kind === 'none') return null;
|
|
211
|
+
if (view.legacyDoc) return legacyAggregate(view.legacyDoc.rows, colId, kind);
|
|
212
|
+
if (colId in readerAggs) return readerVals[colId] ?? null;
|
|
213
|
+
return tab?.aggregateValues?.[colId] ?? null;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const pickAggregate = async (colId: string, kind: AggregateKind) => {
|
|
217
|
+
setReaderAggs((prev) => ({ ...prev, [colId]: kind }));
|
|
218
|
+
if (kind === 'none' || view.legacyDoc || !tab) return;
|
|
219
|
+
setReaderVals((prev) => ({ ...prev, [colId]: null }));
|
|
220
|
+
try {
|
|
221
|
+
const qs = new URLSearchParams({ tab: tab.id, col: colId, kind });
|
|
222
|
+
const r = await fetch(`/s/${token}/aggregate?${qs.toString()}`, { cache: 'no-store' });
|
|
223
|
+
if (!r.ok) return;
|
|
224
|
+
const d = (await r.json()) as { value: number | null };
|
|
225
|
+
setReaderVals((prev) => ({ ...prev, [colId]: d.value }));
|
|
226
|
+
} catch {
|
|
227
|
+
// A failed total stays blank. There is no honest fallback — the rows in
|
|
228
|
+
// hand are a window, and a number from them would be wrong.
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
106
232
|
const embedded = isEmbedded(chrome);
|
|
233
|
+
const anyTotal = columns.some((c) => kindFor(c.id) !== 'none');
|
|
107
234
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<h1 className="text-xl font-semibold tracking-tight">
|
|
116
|
-
{view.icon ? `${view.icon} ` : ''}
|
|
117
|
-
{view.title}
|
|
118
|
-
</h1>
|
|
119
|
-
)}
|
|
120
|
-
<p className={cn('text-xs text-muted-foreground', !embedded && 'mt-1')}>
|
|
121
|
-
{totalRows} row{totalRows === 1 ? '' : 's'}
|
|
122
|
-
</p>
|
|
123
|
-
</header>
|
|
124
|
-
|
|
125
|
-
{tabs.length > 1 && (
|
|
126
|
-
<div className="mb-3 flex flex-wrap gap-1">
|
|
127
|
-
{tabs.map((t) => (
|
|
128
|
-
<button
|
|
129
|
-
key={t.id}
|
|
130
|
-
type="button"
|
|
131
|
-
onClick={() => setTabId(t.id)}
|
|
132
|
-
className={cn(
|
|
133
|
-
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
|
134
|
-
t.id === (tab?.id ?? null)
|
|
135
|
-
? 'bg-primary text-primary-foreground'
|
|
136
|
-
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
137
|
-
)}
|
|
138
|
-
>
|
|
139
|
-
{t.name}
|
|
140
|
-
</button>
|
|
141
|
-
))}
|
|
142
|
-
</div>
|
|
235
|
+
const header = (
|
|
236
|
+
<header className={embedded ? 'mb-3 shrink-0' : 'mb-6 shrink-0 text-center'}>
|
|
237
|
+
{!embedded && (
|
|
238
|
+
<h1 className="text-xl font-semibold tracking-tight">
|
|
239
|
+
{view.icon ? `${view.icon} ` : ''}
|
|
240
|
+
{view.title}
|
|
241
|
+
</h1>
|
|
143
242
|
)}
|
|
243
|
+
<p className={cn('text-xs text-muted-foreground', !embedded && 'mt-1')}>
|
|
244
|
+
{view.legacyDoc || !hasMore
|
|
245
|
+
? `${totalRows} row${totalRows === 1 ? '' : 's'}`
|
|
246
|
+
: `${shownRows.length} of ${totalRows} rows`}
|
|
247
|
+
</p>
|
|
248
|
+
</header>
|
|
249
|
+
);
|
|
144
250
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
251
|
+
const tabStrip = tabs.length > 1 && (
|
|
252
|
+
<div className="mb-3 flex shrink-0 flex-wrap gap-1">
|
|
253
|
+
{tabs.map((t) => (
|
|
254
|
+
<button
|
|
255
|
+
key={t.id}
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => setTabId(t.id)}
|
|
258
|
+
className={cn(
|
|
259
|
+
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
|
260
|
+
t.id === (tab?.id ?? null)
|
|
261
|
+
? 'bg-primary text-primary-foreground'
|
|
262
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
{t.name}
|
|
266
|
+
</button>
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const grid = (
|
|
272
|
+
<table className="w-full border-collapse text-sm">
|
|
273
|
+
{/* Sticky against the scroll box below, not the page: a grid whose
|
|
274
|
+
headers leave the screen at row 40 is a grid of unlabelled numbers. */}
|
|
275
|
+
<thead className="sticky top-0 z-10">
|
|
276
|
+
<tr className="border-b border-border bg-muted">
|
|
277
|
+
{columns.map((c) => {
|
|
278
|
+
const Icon = TYPE_ICON[c.type] ?? Type;
|
|
279
|
+
return (
|
|
280
|
+
<th
|
|
281
|
+
key={c.id}
|
|
282
|
+
className={cn(
|
|
283
|
+
'whitespace-nowrap px-3 py-2 text-left font-medium text-muted-foreground',
|
|
284
|
+
numericCols.has(c.id) && 'text-right',
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
<span
|
|
152
288
|
className={cn(
|
|
153
|
-
'
|
|
154
|
-
numericCols.has(c.id) && '
|
|
289
|
+
'inline-flex items-center gap-1.5',
|
|
290
|
+
numericCols.has(c.id) && 'flex-row-reverse',
|
|
155
291
|
)}
|
|
156
292
|
>
|
|
293
|
+
<Icon className="size-3.5 shrink-0 opacity-70" aria-hidden />
|
|
157
294
|
{c.name}
|
|
158
|
-
</
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
295
|
+
</span>
|
|
296
|
+
</th>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</tr>
|
|
300
|
+
</thead>
|
|
301
|
+
<tbody>
|
|
302
|
+
{shownRows.map((r) => (
|
|
303
|
+
<tr key={r.id} className="border-b border-border/60 last:border-0 hover:bg-muted/30">
|
|
304
|
+
{columns.map((c) => (
|
|
305
|
+
<td
|
|
306
|
+
key={c.id}
|
|
307
|
+
className={cn(
|
|
308
|
+
'max-w-96 truncate px-3 py-1.5',
|
|
309
|
+
numericCols.has(c.id) && 'text-right tabular-nums',
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
<Cell value={r.cells[c.id] ?? null} type={c.type} />
|
|
313
|
+
</td>
|
|
314
|
+
))}
|
|
315
|
+
</tr>
|
|
316
|
+
))}
|
|
317
|
+
{shownRows.length === 0 && !loading && (
|
|
318
|
+
<tr>
|
|
319
|
+
<td
|
|
320
|
+
colSpan={Math.max(columns.length, 1)}
|
|
321
|
+
className="px-3 py-8 text-center text-muted-foreground"
|
|
322
|
+
>
|
|
323
|
+
{failed ? 'Could not load rows.' : 'No rows.'}
|
|
324
|
+
</td>
|
|
325
|
+
</tr>
|
|
326
|
+
)}
|
|
327
|
+
</tbody>
|
|
328
|
+
{/* Always rendered, even with nothing set: the row IS the affordance.
|
|
329
|
+
A member who wants a total needs somewhere to ask for one, and a
|
|
330
|
+
footer that appears only once the owner has already chosen leaves
|
|
331
|
+
them no door. `bg-background` because a transparent sticky row shows
|
|
332
|
+
the scrolling cells through itself. */}
|
|
333
|
+
<tfoot className="sticky bottom-0 z-10">
|
|
334
|
+
<tr className={cn('border-t-2 border-border bg-muted', !anyTotal && 'text-xs')}>
|
|
335
|
+
{columns.map((c) => {
|
|
336
|
+
const kind = kindFor(c.id);
|
|
337
|
+
const value = valueFor(c.id);
|
|
338
|
+
return (
|
|
339
|
+
<td
|
|
340
|
+
key={c.id}
|
|
341
|
+
className={cn(
|
|
342
|
+
'border-l border-border/60 px-1.5 py-1 text-xs first:border-l-0',
|
|
343
|
+
numericCols.has(c.id) && 'text-right',
|
|
344
|
+
)}
|
|
345
|
+
>
|
|
346
|
+
<label className="flex items-center justify-end gap-1.5">
|
|
347
|
+
<span className="sr-only">Total for {c.name}</span>
|
|
348
|
+
{kind !== 'none' && (
|
|
349
|
+
<span className="min-w-0 truncate font-medium tabular-nums">
|
|
350
|
+
{value === null ? (
|
|
351
|
+
// null is a real answer — "this column cannot be
|
|
352
|
+
// totalled that way". A 0 here would be a lie.
|
|
353
|
+
<span className="text-muted-foreground/50">—</span>
|
|
354
|
+
) : (
|
|
355
|
+
formatAggregate(value, kind)
|
|
356
|
+
)}
|
|
357
|
+
</span>
|
|
358
|
+
)}
|
|
359
|
+
<select
|
|
360
|
+
value={kind}
|
|
361
|
+
onChange={(e) => void pickAggregate(c.id, e.target.value as AggregateKind)}
|
|
168
362
|
className={cn(
|
|
169
|
-
'
|
|
170
|
-
|
|
363
|
+
'cursor-pointer rounded-sm border-0 bg-transparent py-0 text-xs outline-none',
|
|
364
|
+
kind === 'none'
|
|
365
|
+
? 'text-muted-foreground/60 hover:text-foreground'
|
|
366
|
+
: 'text-muted-foreground',
|
|
171
367
|
)}
|
|
172
368
|
>
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)}
|
|
188
|
-
</tbody>
|
|
189
|
-
</table>
|
|
190
|
-
</div>
|
|
369
|
+
{AGGREGATE_KINDS.map((k) => (
|
|
370
|
+
<option key={k} value={k}>
|
|
371
|
+
{k === 'none' ? 'Σ' : AGG_LABEL[k]}
|
|
372
|
+
</option>
|
|
373
|
+
))}
|
|
374
|
+
</select>
|
|
375
|
+
</label>
|
|
376
|
+
</td>
|
|
377
|
+
);
|
|
378
|
+
})}
|
|
379
|
+
</tr>
|
|
380
|
+
</tfoot>
|
|
381
|
+
</table>
|
|
382
|
+
);
|
|
191
383
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
384
|
+
const foot = (
|
|
385
|
+
<>
|
|
386
|
+
{loading && (
|
|
387
|
+
<div className="flex shrink-0 items-center justify-center gap-1.5 py-2 text-xs text-muted-foreground">
|
|
388
|
+
<Loader2 className="size-3 animate-spin" aria-hidden /> Loading…
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
{/* Auto-fetch rather than a "Load more" button: scrolling toward the end
|
|
392
|
+
is already the gesture that means "show me the rest". */}
|
|
393
|
+
{!loading && hasMore && tab && (
|
|
394
|
+
<MoreSentinel onReveal={() => void fetchPage(shownRows.length, tab.id)} />
|
|
395
|
+
)}
|
|
396
|
+
</>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// Embedded, the grid OWNS the height it was given: the header and tab strip
|
|
400
|
+
// are fixed, and the table scrolls inside a bounded box so its sticky header
|
|
401
|
+
// and footer have something to stick to. On the standalone /s page there is
|
|
402
|
+
// no height to own — the page scrolls, the table simply grows, and sticky
|
|
403
|
+
// has nothing to do.
|
|
404
|
+
if (embedded) {
|
|
405
|
+
return (
|
|
406
|
+
<div className="flex h-full min-h-0 w-full flex-col px-6 py-6">
|
|
407
|
+
{header}
|
|
408
|
+
{tabStrip}
|
|
409
|
+
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-border scrollbar-thin">
|
|
410
|
+
{grid}
|
|
411
|
+
{foot}
|
|
412
|
+
</div>
|
|
207
413
|
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<div className="mx-auto max-w-6xl px-6 py-12">
|
|
419
|
+
{header}
|
|
420
|
+
{tabStrip}
|
|
421
|
+
<div className="overflow-x-auto rounded-lg border border-border">{grid}</div>
|
|
422
|
+
{foot}
|
|
208
423
|
</div>
|
|
209
424
|
);
|
|
210
425
|
}
|
|
211
426
|
|
|
427
|
+
/**
|
|
428
|
+
* The legacy path's total. Safe ONLY here: a legacy table's rows arrive whole
|
|
429
|
+
* in the share view, so this is the complete set, not a window. Mirrors
|
|
430
|
+
* computeAggregate's numeric semantics — non-numeric cells are ignored by the
|
|
431
|
+
* numeric kinds rather than counted as zero.
|
|
432
|
+
*/
|
|
433
|
+
function legacyAggregate(rows: PublicRow[], colId: string, kind: AggregateKind): number | null {
|
|
434
|
+
const isEmptyCell = (v: CellValue) =>
|
|
435
|
+
v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
|
|
436
|
+
if (kind === 'count') return rows.length;
|
|
437
|
+
if (kind === 'filled') return rows.filter((r) => !isEmptyCell(r.cells[colId] ?? null)).length;
|
|
438
|
+
if (kind === 'empty') return rows.filter((r) => isEmptyCell(r.cells[colId] ?? null)).length;
|
|
439
|
+
const nums = rows
|
|
440
|
+
.map((r) => {
|
|
441
|
+
const v = r.cells[colId];
|
|
442
|
+
if (typeof v === 'number') return v;
|
|
443
|
+
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
|
|
444
|
+
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
445
|
+
return null;
|
|
446
|
+
})
|
|
447
|
+
.filter((n): n is number => n !== null);
|
|
448
|
+
if (nums.length === 0) return null;
|
|
449
|
+
switch (kind) {
|
|
450
|
+
case 'sum':
|
|
451
|
+
return nums.reduce((a, b) => a + b, 0);
|
|
452
|
+
case 'avg':
|
|
453
|
+
return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
454
|
+
case 'min':
|
|
455
|
+
return Math.min(...nums);
|
|
456
|
+
case 'max':
|
|
457
|
+
return Math.max(...nums);
|
|
458
|
+
default:
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Fetches the next page when it scrolls into view. */
|
|
464
|
+
function MoreSentinel({ onReveal }: { onReveal: () => void }) {
|
|
465
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
466
|
+
// The callback changes identity every render, so the observer reads it from a
|
|
467
|
+
// ref — re-creating the observer each render would disconnect it mid-scroll
|
|
468
|
+
// and the grid would stop growing until the reader scrolled again.
|
|
469
|
+
const reveal = useRef(onReveal);
|
|
470
|
+
reveal.current = onReveal;
|
|
471
|
+
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
const el = ref.current;
|
|
474
|
+
if (!el) return;
|
|
475
|
+
const io = new IntersectionObserver(
|
|
476
|
+
(entries) => entries[0]?.isIntersecting && reveal.current(),
|
|
477
|
+
{ rootMargin: '400px' },
|
|
478
|
+
);
|
|
479
|
+
io.observe(el);
|
|
480
|
+
return () => io.disconnect();
|
|
481
|
+
}, []);
|
|
482
|
+
|
|
483
|
+
return <div ref={ref} className="h-8 shrink-0" aria-hidden />;
|
|
484
|
+
}
|
|
485
|
+
|
|
212
486
|
function Cell({ value, type }: { value: CellValue; type: string }) {
|
|
213
487
|
if (value === null || value === undefined || value === '') {
|
|
214
488
|
return <span className="text-muted-foreground/50">—</span>;
|
package/src/view-payload.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { TocEntry } from '@mantle/content-core/page-toc';
|
|
|
16
16
|
import type { CoverageGap, FormulaSpec } from '@mantle/content-core/formula-spec';
|
|
17
17
|
import type { TargetSignature } from '@mantle/content-core/formula-signature';
|
|
18
18
|
import type { DimensionIssue } from '@mantle/content-core/formula-dimensions';
|
|
19
|
-
import type { Column, Row } from '@mantle/content-core/table-model';
|
|
19
|
+
import type { AggregateKind, Column, Row } from '@mantle/content-core/table-model';
|
|
20
20
|
|
|
21
21
|
export type ShareFolderListing = {
|
|
22
22
|
/** ltree path currently being listed (the shared root or a descendant). */
|
|
@@ -75,8 +75,29 @@ export type ShareViewPayload =
|
|
|
75
75
|
name: string;
|
|
76
76
|
rowCount: number;
|
|
77
77
|
columns: Array<{ id: string; name: string; type: string }>;
|
|
78
|
+
/**
|
|
79
|
+
* The owner's footer totals for this tab: colId → kind, and the VALUE
|
|
80
|
+
* computed server-side over the whole tab.
|
|
81
|
+
*
|
|
82
|
+
* The value has to come from the server and this is the whole reason
|
|
83
|
+
* the field exists. A reader holds one 200-row window at a time, so a
|
|
84
|
+
* sum taken over what it happens to have loaded is not a smaller
|
|
85
|
+
* number — it is a WRONG one, and wrong quietly, which is worse than
|
|
86
|
+
* absent. `aggregateWindow` runs it in SQL over every row.
|
|
87
|
+
*
|
|
88
|
+
* Optional: an older server sends neither, and the footer simply does
|
|
89
|
+
* not render.
|
|
90
|
+
*/
|
|
91
|
+
aggregates?: Record<string, AggregateKind>;
|
|
92
|
+
aggregateValues?: Record<string, number | null>;
|
|
78
93
|
}> | null;
|
|
79
|
-
legacyDoc: {
|
|
94
|
+
legacyDoc: {
|
|
95
|
+
columns: Column[];
|
|
96
|
+
rows: Row[];
|
|
97
|
+
/** Legacy tables arrive WHOLE, so the reader can compute these itself
|
|
98
|
+
* with `computeAggregate` and no endpoint is involved. Settings only. */
|
|
99
|
+
aggregates?: Record<string, AggregateKind>;
|
|
100
|
+
} | null;
|
|
80
101
|
}
|
|
81
102
|
| {
|
|
82
103
|
kind: 'formula';
|