@casualoffice/sheets 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/chrome.cjs +3339 -244
  2. package/dist/chrome.cjs.map +1 -1
  3. package/dist/chrome.js +3298 -203
  4. package/dist/chrome.js.map +1 -1
  5. package/dist/embed/embed-runtime.js +203 -157
  6. package/dist/index.cjs +5971 -1572
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +5961 -1548
  9. package/dist/index.js.map +1 -1
  10. package/dist/sheets.cjs +4937 -538
  11. package/dist/sheets.cjs.map +1 -1
  12. package/dist/sheets.js +4945 -532
  13. package/dist/sheets.js.map +1 -1
  14. package/package.json +8 -7
  15. package/src/charts/ChartContextMenu.tsx +264 -0
  16. package/src/charts/ChartLayer.tsx +333 -0
  17. package/src/charts/ChartOverlay.tsx +293 -0
  18. package/src/charts/ChartsPanel.tsx +211 -0
  19. package/src/charts/FormatChartDialog.tsx +419 -0
  20. package/src/charts/InsertChartDialog.tsx +478 -0
  21. package/src/charts/build-option.ts +476 -0
  22. package/src/charts/charts-context.tsx +205 -0
  23. package/src/charts/echarts-init.ts +58 -0
  24. package/src/charts/hit-test.ts +130 -0
  25. package/src/charts/insert-chart.ts +106 -0
  26. package/src/charts/naming.ts +38 -0
  27. package/src/charts/render-to-png.ts +117 -0
  28. package/src/charts/resources.ts +108 -0
  29. package/src/charts/types.ts +239 -0
  30. package/src/charts/univer-dom.ts +102 -0
  31. package/src/chrome/CommentsPanel.tsx +427 -0
  32. package/src/chrome/ConditionalFormattingDialog.tsx +534 -0
  33. package/src/chrome/CustomSortDialog.tsx +357 -0
  34. package/src/chrome/DataValidationDialog.tsx +536 -0
  35. package/src/chrome/DeleteCellsDialog.tsx +183 -0
  36. package/src/chrome/GoalSeekDialog.tsx +370 -0
  37. package/src/chrome/HistoryPanel.tsx +319 -0
  38. package/src/chrome/InsertCellsDialog.tsx +185 -0
  39. package/src/chrome/InsertChartDialog.tsx +490 -0
  40. package/src/chrome/InsertFunctionDialog.tsx +493 -0
  41. package/src/chrome/InsertPivotDialog.tsx +488 -0
  42. package/src/chrome/InsertSparklineDialog.tsx +344 -0
  43. package/src/chrome/NameManagerDialog.tsx +378 -0
  44. package/src/chrome/PanelHost.tsx +55 -0
  45. package/src/chrome/PanelRail.tsx +90 -0
  46. package/src/chrome/PasteSpecialDialog.tsx +286 -0
  47. package/src/chrome/PivotFieldsPanel.tsx +1052 -0
  48. package/src/chrome/TablesPanel.tsx +301 -0
  49. package/src/chrome/dialog-context.tsx +24 -0
  50. package/src/chrome/panel-context.tsx +55 -0
  51. package/src/chrome/panel-registry.ts +48 -0
  52. package/src/sheets/CasualSheets.tsx +60 -34
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License").
5
+ */
6
+
7
+ /**
8
+ * PivotTable Fields side panel — Excel's "PivotTable Fields" task pane, ported
9
+ * from the standalone app's PivotFieldsPanel into the SDK chrome so embedders
10
+ * (dochub) get it natively.
11
+ *
12
+ * Lists the source fields of a pivot and its four drop zones — Filters /
13
+ * Columns / Rows / Values — and lets the user assign fields (drag, or the "+"
14
+ * zone menu), remove/reorder chips, edit a value's aggregation + Show-Values-As,
15
+ * a row field's date grouping, and a report filter's per-value checklist. The
16
+ * pivot config is persisted on the workbook so it round-trips through xlsx and
17
+ * collab.
18
+ *
19
+ * DECOUPLING from the app:
20
+ * - `useUniverAPI()` → the injected `api` prop; `useUI().togglePivotPanel` →
21
+ * the `onClose` prop.
22
+ * - The app reads/writes pivot models through its `PivotsProvider` context
23
+ * (apps/web/src/pivots/pivots-context.tsx), which does NOT exist in the SDK.
24
+ * Instead — mirroring the SDK's InsertSparklineDialog — we read the pivot
25
+ * models straight off the live workbook snapshot's
26
+ * `resources['__casual_sheets_pivots__']` via `api.getContent()` and persist
27
+ * edits back with `api.setContent()`. Same resource envelope the app's
28
+ * pivots feature owns; it round-trips through xlsx + collab identically.
29
+ * - The pure `fields-model` transforms + label tables + source reader are
30
+ * INLINED here (structurally identical to apps/web/src/pivots/*) so the SDK
31
+ * stays free of the app package.
32
+ *
33
+ * COMPUTE GAP (honest limitation): the app's pivot *engine* — `computePivot` /
34
+ * `applyPivot` (apps/web/src/pivots/{compute,apply}.ts) — is app-only code, not
35
+ * a Univer plugin, and porting it is out of scope for this PR. This panel fully
36
+ * edits + persists the pivot's field configuration (definition), but it does NOT
37
+ * re-render the output cell grid: the laid-out pivot rectangle on the sheet only
38
+ * refreshes once the app engine re-applies the model. So field assignment,
39
+ * drag-and-drop, agg/showAs/grouping/filters all work and are saved, but the
40
+ * visible output lags the config until the engine port lands (a later PR).
41
+ */
42
+
43
+ import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
44
+ import type { IWorkbookData } from '@univerjs/core';
45
+
46
+ import type { CasualSheetsAPI } from '../sheets/api';
47
+ import type { PanelComponentProps } from './extensions';
48
+ import { Icon } from './Icon';
49
+
50
+ /* ------------------------------------------------------------------ *
51
+ * Pivot model — mirrors apps/web/src/pivots/types.ts (duplicated so the
52
+ * SDK doesn't depend on the app package).
53
+ * ------------------------------------------------------------------ */
54
+
55
+ type PivotAggregation = 'sum' | 'count' | 'average' | 'min' | 'max' | 'distinctCount';
56
+ const PIVOT_AGG_LABELS: Record<PivotAggregation, string> = {
57
+ sum: 'Sum',
58
+ count: 'Count',
59
+ average: 'Average',
60
+ min: 'Min',
61
+ max: 'Max',
62
+ distinctCount: 'Distinct Count',
63
+ };
64
+
65
+ type DateGrouping = 'none' | 'year' | 'quarter' | 'month';
66
+ const PIVOT_DATE_GROUP_LABELS: Record<DateGrouping, string> = {
67
+ none: 'No grouping',
68
+ year: 'Years',
69
+ quarter: 'Quarters',
70
+ month: 'Months',
71
+ };
72
+
73
+ type PivotShowAs = 'normal' | 'pctOfGrandTotal' | 'pctOfColumnTotal' | 'pctOfRowTotal';
74
+ const PIVOT_SHOW_AS_LABELS: Record<PivotShowAs, string> = {
75
+ normal: 'Normal',
76
+ pctOfGrandTotal: '% of Grand Total',
77
+ pctOfColumnTotal: '% of Column Total',
78
+ pctOfRowTotal: '% of Row Total',
79
+ };
80
+
81
+ type PivotFieldRef = { column: number; grouping?: DateGrouping };
82
+ type PivotValueField = { column: number; agg: PivotAggregation; showAs?: PivotShowAs };
83
+ type PivotFilter = { column: number; allowedValues: string[] };
84
+
85
+ interface PivotModel {
86
+ id: string;
87
+ sourceSheetId: string;
88
+ source: { startRow: number; endRow: number; startColumn: number; endColumn: number };
89
+ targetSheetId: string;
90
+ target: { row: number; column: number };
91
+ rows: PivotFieldRef[];
92
+ cols: PivotFieldRef[];
93
+ values: PivotValueField[];
94
+ filters?: PivotFilter[];
95
+ lastOutputExtent?: { rows: number; cols: number };
96
+ title?: string;
97
+ }
98
+
99
+ /** Resource key + envelope — mirrors `PIVOTS_RESOURCE_NAME` /
100
+ * `PivotsResourceV1` in apps/web/src/pivots/types.ts. */
101
+ const PIVOTS_RESOURCE_NAME = '__casual_sheets_pivots__';
102
+ interface PivotsResourceV1 {
103
+ v: 1;
104
+ pivots: PivotModel[];
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ *
108
+ * Pure model transforms — mirrors apps/web/src/pivots/fields-model.ts.
109
+ * ------------------------------------------------------------------ */
110
+
111
+ type ZoneId = 'filters' | 'rows' | 'cols' | 'values';
112
+
113
+ const ZONE_LABELS: Record<ZoneId, string> = {
114
+ filters: 'Filters',
115
+ cols: 'Columns',
116
+ rows: 'Rows',
117
+ values: 'Values',
118
+ };
119
+
120
+ const AXES: Array<Exclude<ZoneId, 'values'>> = ['rows', 'cols', 'filters'];
121
+
122
+ type DragPayload = { from: 'list'; column: number } | { from: 'zone'; zone: ZoneId; index: number };
123
+
124
+ function placedColumns(model: PivotModel): Set<number> {
125
+ const s = new Set<number>();
126
+ for (const r of model.rows) s.add(r.column);
127
+ for (const c of model.cols) s.add(c.column);
128
+ for (const v of model.values) s.add(v.column);
129
+ for (const f of model.filters ?? []) s.add(f.column);
130
+ return s;
131
+ }
132
+
133
+ function cloneZones(model: PivotModel) {
134
+ return {
135
+ rows: model.rows.map((r) => ({ ...r })),
136
+ cols: model.cols.map((c) => ({ ...c })),
137
+ values: model.values.map((v) => ({ ...v })),
138
+ filters: (model.filters ?? []).map((f) => ({ ...f, allowedValues: [...f.allowedValues] })),
139
+ };
140
+ }
141
+
142
+ function stripFromAxes(z: ReturnType<typeof cloneZones>, column: number): void {
143
+ z.rows = z.rows.filter((r) => r.column !== column);
144
+ z.cols = z.cols.filter((c) => c.column !== column);
145
+ z.filters = z.filters.filter((f) => f.column !== column);
146
+ }
147
+
148
+ function addFieldToZone(
149
+ model: PivotModel,
150
+ column: number,
151
+ zone: ZoneId,
152
+ opts?: { defaultAgg?: PivotAggregation; allowedValues?: string[] },
153
+ ): PivotModel {
154
+ const z = cloneZones(model);
155
+ if (zone === 'values') {
156
+ z.values.push({ column, agg: opts?.defaultAgg ?? 'sum', showAs: 'normal' });
157
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
158
+ }
159
+ stripFromAxes(z, column);
160
+ if (zone === 'rows') z.rows.push({ column });
161
+ else if (zone === 'cols') z.cols.push({ column });
162
+ else z.filters.push({ column, allowedValues: opts?.allowedValues ?? [] });
163
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
164
+ }
165
+
166
+ function removeFieldFromZone(model: PivotModel, zone: ZoneId, index: number): PivotModel {
167
+ const z = cloneZones(model);
168
+ z[zone] = (z[zone] as unknown[]).filter((_, i) => i !== index) as never;
169
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
170
+ }
171
+
172
+ function moveWithinZone(model: PivotModel, zone: ZoneId, from: number, to: number): PivotModel {
173
+ const z = cloneZones(model);
174
+ const arr = z[zone] as unknown[];
175
+ if (from < 0 || from >= arr.length || to < 0 || to >= arr.length || from === to) return model;
176
+ const [moved] = arr.splice(from, 1);
177
+ arr.splice(to, 0, moved);
178
+ return { ...model, rows: z.rows, cols: z.cols, values: z.values, filters: z.filters };
179
+ }
180
+
181
+ function updateValueField(
182
+ model: PivotModel,
183
+ index: number,
184
+ patch: Partial<Pick<PivotValueField, 'agg' | 'showAs'>>,
185
+ ): PivotModel {
186
+ const values = model.values.map((v, i) => (i === index ? { ...v, ...patch } : v));
187
+ return { ...model, values };
188
+ }
189
+
190
+ function updateRowGrouping(model: PivotModel, index: number, grouping: DateGrouping): PivotModel {
191
+ const rows = model.rows.map((r, i) =>
192
+ i === index ? { ...r, grouping: grouping === 'none' ? undefined : grouping } : r,
193
+ );
194
+ return { ...model, rows };
195
+ }
196
+
197
+ function hasValues(model: PivotModel): boolean {
198
+ return model.values.length > 0;
199
+ }
200
+
201
+ function axisOf(model: PivotModel, column: number): Exclude<ZoneId, 'values'> | null {
202
+ for (const ax of AXES) {
203
+ const arr = ax === 'filters' ? (model.filters ?? []) : model[ax];
204
+ if (arr.some((e) => e.column === column)) return ax;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ function toggleFilterValue(
210
+ model: PivotModel,
211
+ filterIndex: number,
212
+ value: string,
213
+ checked: boolean,
214
+ allValues: string[],
215
+ ): PivotModel {
216
+ const filters = (model.filters ?? []).map((f) => ({ ...f, allowedValues: [...f.allowedValues] }));
217
+ const f = filters[filterIndex];
218
+ if (!f) return model;
219
+ const current = new Set(f.allowedValues.length ? f.allowedValues : allValues);
220
+ if (checked) current.add(value);
221
+ else current.delete(value);
222
+ f.allowedValues = allValues.filter((v) => current.has(v));
223
+ return { ...model, filters };
224
+ }
225
+
226
+ function setFilterValues(model: PivotModel, filterIndex: number, values: string[]): PivotModel {
227
+ const filters = (model.filters ?? []).map((f) => ({ ...f, allowedValues: [...f.allowedValues] }));
228
+ if (!filters[filterIndex]) return model;
229
+ filters[filterIndex].allowedValues = [...values];
230
+ return { ...model, filters };
231
+ }
232
+
233
+ function filterAllowedCount(model: PivotModel, filterIndex: number, allCount: number): number {
234
+ const f = (model.filters ?? [])[filterIndex];
235
+ if (!f) return allCount;
236
+ return f.allowedValues.length ? f.allowedValues.length : allCount;
237
+ }
238
+
239
+ function columnInZone(model: PivotModel, zone: ZoneId, index: number): number | null {
240
+ const arr =
241
+ zone === 'values' ? model.values : zone === 'filters' ? (model.filters ?? []) : model[zone];
242
+ return arr[index]?.column ?? null;
243
+ }
244
+
245
+ function applyDrop(
246
+ model: PivotModel,
247
+ payload: DragPayload,
248
+ targetZone: ZoneId,
249
+ optsFor: (column: number) => { defaultAgg?: PivotAggregation; allowedValues?: string[] },
250
+ ): PivotModel {
251
+ if (payload.from === 'list') {
252
+ return addFieldToZone(model, payload.column, targetZone, optsFor(payload.column));
253
+ }
254
+ if (payload.zone === targetZone) return model;
255
+ const column = columnInZone(model, payload.zone, payload.index);
256
+ if (column == null) return model;
257
+ if (payload.zone === 'values' && model.values.length <= 1) return model;
258
+ const removed = removeFieldFromZone(model, payload.zone, payload.index);
259
+ return addFieldToZone(removed, column, targetZone, optsFor(column));
260
+ }
261
+
262
+ /* ------------------------------------------------------------------ *
263
+ * Snapshot resource read/write — the SDK-native persistence path
264
+ * (mirrors InsertSparklineDialog): read models off `api.getContent()`,
265
+ * write back through `api.setContent()`.
266
+ * ------------------------------------------------------------------ */
267
+
268
+ function readPivots(api: CasualSheetsAPI): PivotModel[] {
269
+ const data = api.getContent();
270
+ const entry = data?.resources?.find((r) => r.name === PIVOTS_RESOURCE_NAME);
271
+ if (!entry?.data) return [];
272
+ try {
273
+ const parsed = JSON.parse(entry.data) as Partial<PivotsResourceV1>;
274
+ if (parsed?.v !== 1 || !Array.isArray(parsed.pivots)) return [];
275
+ return parsed.pivots;
276
+ } catch {
277
+ return [];
278
+ }
279
+ }
280
+
281
+ /** Persist an edited pivot model back onto the workbook snapshot. Merges over
282
+ * the existing entry and re-mounts via `setContent` — the same path the
283
+ * sparkline dialog uses. */
284
+ function persistPivot(api: CasualSheetsAPI, next: PivotModel): void {
285
+ const data = api.getContent();
286
+ if (!data) return;
287
+ const resources = data.resources ? [...data.resources] : [];
288
+ const idx = resources.findIndex((r) => r.name === PIVOTS_RESOURCE_NAME);
289
+ let pivots: PivotModel[] = [];
290
+ if (idx >= 0 && resources[idx]?.data) {
291
+ try {
292
+ const parsed = JSON.parse(resources[idx].data) as Partial<PivotsResourceV1>;
293
+ if (parsed?.v === 1 && Array.isArray(parsed.pivots)) pivots = parsed.pivots;
294
+ } catch {
295
+ pivots = [];
296
+ }
297
+ }
298
+ const merged = pivots.some((p) => p.id === next.id)
299
+ ? pivots.map((p) => (p.id === next.id ? next : p))
300
+ : [...pivots, next];
301
+ const entry = { name: PIVOTS_RESOURCE_NAME, data: JSON.stringify({ v: 1, pivots: merged }) };
302
+ if (idx >= 0) resources[idx] = entry;
303
+ else resources.push(entry);
304
+ const nextData: IWorkbookData = { ...data, resources };
305
+ api.setContent(nextData);
306
+ }
307
+
308
+ /* ------------------------------------------------------------------ *
309
+ * Source reader — headers + distinct values off the FUniver facade.
310
+ * Mirrors readSource() in apps/web/src/pivots/PivotFieldsPanel.tsx.
311
+ * ------------------------------------------------------------------ */
312
+
313
+ interface SourceView {
314
+ headers: string[];
315
+ distinct: (col: number) => string[];
316
+ isNumeric: (col: number) => boolean;
317
+ }
318
+
319
+ /* eslint-disable @typescript-eslint/no-explicit-any */
320
+ function readSource(api: CasualSheetsAPI, model: PivotModel | null): SourceView {
321
+ const empty: SourceView = { headers: [], distinct: () => [], isNumeric: () => false };
322
+ if (!model) return empty;
323
+ const wb = api.univer.getActiveWorkbook?.();
324
+ if (!wb) return empty;
325
+ const ws = (wb.getSheets() as any[]).find((s) => s.getSheetId?.() === model.sourceSheetId);
326
+ if (!ws) return empty;
327
+ const { startRow, endRow, startColumn, endColumn } = model.source;
328
+ const headers: string[] = [];
329
+ for (let c = startColumn; c <= endColumn; c++) {
330
+ const v = ws.getRange(startRow, c).getValue();
331
+ headers.push(v == null || v === '' ? `Column ${c - startColumn + 1}` : String(v));
332
+ }
333
+ const colAt = (idx: number) => startColumn + idx;
334
+ return {
335
+ headers,
336
+ distinct: (col) => {
337
+ const seen = new Set<string>();
338
+ for (let r = startRow + 1; r <= endRow; r++) {
339
+ const v = ws.getRange(r, colAt(col)).getValue();
340
+ seen.add(v == null ? '' : String(v));
341
+ }
342
+ return [...seen].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
343
+ },
344
+ isNumeric: (col) => {
345
+ for (let r = startRow + 1; r <= endRow; r++) {
346
+ const v = ws.getRange(r, colAt(col)).getValue();
347
+ if (v == null || v === '') continue;
348
+ return typeof v === 'number';
349
+ }
350
+ return false;
351
+ },
352
+ };
353
+ }
354
+ /* eslint-enable @typescript-eslint/no-explicit-any */
355
+
356
+ /* ------------------------------------------------------------------ *
357
+ * Styles — inline, --cs-chrome-* CSS vars, no external classes.
358
+ * ------------------------------------------------------------------ */
359
+
360
+ const border = '1px solid var(--cs-chrome-border, #edeff3)';
361
+
362
+ const headerStyle: CSSProperties = {
363
+ display: 'flex',
364
+ alignItems: 'center',
365
+ gap: 8,
366
+ padding: '10px 12px',
367
+ borderBottom: border,
368
+ };
369
+
370
+ const iconBtn: CSSProperties = {
371
+ border: 'none',
372
+ background: 'transparent',
373
+ cursor: 'pointer',
374
+ color: 'inherit',
375
+ display: 'inline-flex',
376
+ alignItems: 'center',
377
+ padding: 2,
378
+ };
379
+
380
+ const sectionTitle: CSSProperties = {
381
+ margin: '0 0 6px',
382
+ fontSize: 11,
383
+ fontWeight: 700,
384
+ textTransform: 'uppercase',
385
+ letterSpacing: 0.4,
386
+ opacity: 0.6,
387
+ };
388
+
389
+ const fieldRow: CSSProperties = {
390
+ display: 'flex',
391
+ alignItems: 'center',
392
+ gap: 6,
393
+ padding: '5px 6px',
394
+ borderRadius: 6,
395
+ cursor: 'grab',
396
+ userSelect: 'none',
397
+ };
398
+
399
+ const chipStyle: CSSProperties = {
400
+ border,
401
+ borderRadius: 6,
402
+ padding: '6px 8px',
403
+ background: 'var(--cs-chrome-surface, #fbfbfd)',
404
+ display: 'flex',
405
+ flexDirection: 'column',
406
+ gap: 6,
407
+ };
408
+
409
+ const selectStyle: CSSProperties = {
410
+ font: 'inherit',
411
+ fontSize: 12,
412
+ padding: '2px 4px',
413
+ border,
414
+ borderRadius: 4,
415
+ background: 'transparent',
416
+ color: 'inherit',
417
+ flex: 1,
418
+ };
419
+
420
+ /* ------------------------------------------------------------------ *
421
+ * Panel component.
422
+ * ------------------------------------------------------------------ */
423
+
424
+ export function PivotFieldsPanel({ api, onClose }: PanelComponentProps) {
425
+ const [pivots, setPivots] = useState<PivotModel[]>(() => readPivots(api));
426
+ const [selectedId, setSelectedId] = useState<string | null>(null);
427
+ const [menuFor, setMenuFor] = useState<number | null>(null);
428
+ const [expandedFilter, setExpandedFilter] = useState<number | null>(null);
429
+
430
+ // Re-read pivots whenever the workbook content settles (edit / import /
431
+ // setContent / collab). Keeps the panel live without the app pivot store.
432
+ useEffect(() => {
433
+ const reload = () => setPivots(readPivots(api));
434
+ reload();
435
+ const offChange = api.on('change', reload);
436
+ return () => offChange();
437
+ }, [api]);
438
+
439
+ // Keep a valid selection: default to the most-recent pivot; recover if the
440
+ // selected one was deleted.
441
+ useEffect(() => {
442
+ if (pivots.length === 0) {
443
+ if (selectedId !== null) setSelectedId(null);
444
+ return;
445
+ }
446
+ if (!selectedId || !pivots.some((p) => p.id === selectedId)) {
447
+ setSelectedId(pivots[pivots.length - 1].id);
448
+ }
449
+ }, [pivots, selectedId]);
450
+
451
+ const model = useMemo(
452
+ () => pivots.find((p) => p.id === selectedId) ?? null,
453
+ [pivots, selectedId],
454
+ );
455
+ const source = useMemo(() => readSource(api, model), [api, model]);
456
+
457
+ const placed = model ? placedColumns(model) : new Set<number>();
458
+
459
+ // Optimistic local update + persist. We update `pivots` immediately so the UI
460
+ // is responsive, then write through to the snapshot so it round-trips.
461
+ const commit = (next: PivotModel) => {
462
+ setPivots((prev) => prev.map((p) => (p.id === next.id ? next : p)));
463
+ persistPivot(api, next);
464
+ };
465
+
466
+ const optsFor = (col: number) => ({
467
+ defaultAgg: source.isNumeric(col) ? ('sum' as const) : ('count' as const),
468
+ allowedValues: source.distinct(col),
469
+ });
470
+
471
+ const onZoneDrop = (zone: ZoneId, raw: string) => {
472
+ if (!model || !raw) return;
473
+ let payload: DragPayload;
474
+ try {
475
+ payload = JSON.parse(raw) as DragPayload;
476
+ } catch {
477
+ return;
478
+ }
479
+ commit(applyDrop(model, payload, zone, optsFor));
480
+ };
481
+
482
+ const labelFor = (col: number) => source.headers[col] ?? `Column ${col + 1}`;
483
+
484
+ return (
485
+ <div
486
+ data-testid="cs-pivot-fields-panel"
487
+ style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
488
+ >
489
+ <header style={headerStyle}>
490
+ <Icon name="pivot_table_chart" size={18} />
491
+ <span style={{ fontWeight: 600, flex: 1 }}>PivotTable Fields</span>
492
+ <button
493
+ type="button"
494
+ aria-label="Close PivotTable Fields panel"
495
+ onClick={onClose}
496
+ style={iconBtn}
497
+ >
498
+ <Icon name="close" size={18} />
499
+ </button>
500
+ </header>
501
+
502
+ <div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
503
+ {!model ? (
504
+ <div
505
+ data-testid="cs-pivot-fields-empty"
506
+ style={{ textAlign: 'center', opacity: 0.75, padding: '24px 8px' }}
507
+ >
508
+ <Icon name="pivot_table_chart" size={40} style={{ opacity: 0.4 }} />
509
+ <div style={{ fontWeight: 600, marginTop: 8 }}>No PivotTable selected</div>
510
+ <div style={{ fontSize: 13, marginTop: 4 }}>
511
+ Insert a PivotTable, then configure its fields here.
512
+ </div>
513
+ </div>
514
+ ) : (
515
+ <>
516
+ {pivots.length > 1 && (
517
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
518
+ <span style={{ fontSize: 12, opacity: 0.7 }}>PivotTable</span>
519
+ <select
520
+ data-testid="cs-pivot-fields-picker"
521
+ value={selectedId ?? ''}
522
+ onChange={(e) => setSelectedId(e.target.value)}
523
+ style={{ ...selectStyle, flex: 1 }}
524
+ >
525
+ {pivots.map((p, i) => (
526
+ <option key={p.id} value={p.id}>
527
+ {p.title ?? `PivotTable ${i + 1}`}
528
+ </option>
529
+ ))}
530
+ </select>
531
+ </label>
532
+ )}
533
+
534
+ {/* Compute-gap notice — field config saves + round-trips, but the
535
+ output grid only re-renders when the app pivot engine re-applies. */}
536
+ <div
537
+ data-testid="cs-pivot-fields-apply-notice"
538
+ style={{
539
+ display: 'flex',
540
+ gap: 6,
541
+ fontSize: 11,
542
+ lineHeight: 1.4,
543
+ opacity: 0.75,
544
+ background: 'var(--cs-chrome-surface, #f4f6fb)',
545
+ border,
546
+ borderRadius: 6,
547
+ padding: '6px 8px',
548
+ marginBottom: 12,
549
+ }}
550
+ >
551
+ <Icon name="info" size={14} style={{ opacity: 0.7, flexShrink: 0, marginTop: 1 }} />
552
+ <span>
553
+ Field changes are saved to the PivotTable. The output cells refresh once the pivot
554
+ engine re-applies the layout.
555
+ </span>
556
+ </div>
557
+
558
+ {/* Source field list */}
559
+ <section style={{ marginBottom: 14 }}>
560
+ <h3 style={sectionTitle}>Choose fields</h3>
561
+ <ul
562
+ data-testid="cs-pivot-fields-list"
563
+ style={{ listStyle: 'none', margin: 0, padding: 0 }}
564
+ >
565
+ {source.headers.map((h, col) => {
566
+ const ax = axisOf(model, col);
567
+ const badge =
568
+ ax === 'rows' ? 'R' : ax === 'cols' ? 'C' : ax === 'filters' ? '▽' : '';
569
+ return (
570
+ <li
571
+ key={col}
572
+ style={fieldRow}
573
+ draggable
574
+ data-testid={`cs-pivot-fields-field-${col}`}
575
+ onDragStart={(e) => {
576
+ e.dataTransfer.effectAllowed = 'move';
577
+ e.dataTransfer.setData(
578
+ 'text/plain',
579
+ JSON.stringify({ from: 'list', column: col } satisfies DragPayload),
580
+ );
581
+ }}
582
+ >
583
+ <Icon name="drag_indicator" size={16} style={{ opacity: 0.4 }} />
584
+ <span
585
+ aria-hidden
586
+ style={{
587
+ width: 8,
588
+ height: 8,
589
+ borderRadius: '50%',
590
+ flexShrink: 0,
591
+ background: placed.has(col)
592
+ ? 'var(--cs-chrome-active-fg, #0e7490)'
593
+ : 'transparent',
594
+ border: placed.has(col)
595
+ ? 'none'
596
+ : '1px solid var(--cs-chrome-border, #c8ccd4)',
597
+ }}
598
+ />
599
+ <span
600
+ title={h}
601
+ style={{
602
+ flex: 1,
603
+ overflow: 'hidden',
604
+ textOverflow: 'ellipsis',
605
+ whiteSpace: 'nowrap',
606
+ }}
607
+ >
608
+ {h}
609
+ </span>
610
+ {badge && (
611
+ <span
612
+ style={{
613
+ fontSize: 11,
614
+ fontWeight: 700,
615
+ opacity: 0.6,
616
+ minWidth: 14,
617
+ textAlign: 'center',
618
+ }}
619
+ >
620
+ {badge}
621
+ </span>
622
+ )}
623
+ <div style={{ position: 'relative' }}>
624
+ <button
625
+ type="button"
626
+ aria-label={`Add ${h} to a zone`}
627
+ data-testid={`cs-pivot-fields-add-${col}`}
628
+ onClick={() => setMenuFor((cur) => (cur === col ? null : col))}
629
+ style={iconBtn}
630
+ >
631
+ <Icon name="add" size={16} />
632
+ </button>
633
+ {menuFor === col && (
634
+ <div
635
+ role="menu"
636
+ data-testid={`cs-pivot-fields-add-menu-${col}`}
637
+ style={{
638
+ position: 'absolute',
639
+ right: 0,
640
+ top: '100%',
641
+ zIndex: 10,
642
+ minWidth: 150,
643
+ background: 'var(--cs-chrome-bg, #fff)',
644
+ border,
645
+ borderRadius: 6,
646
+ boxShadow: '0 4px 16px rgba(0,0,0,0.14)',
647
+ padding: 4,
648
+ }}
649
+ >
650
+ {(['filters', 'rows', 'cols', 'values'] as ZoneId[]).map((zone) => (
651
+ <button
652
+ key={zone}
653
+ type="button"
654
+ role="menuitem"
655
+ data-testid={`cs-pivot-fields-add-${col}-${zone}`}
656
+ onClick={() => {
657
+ setMenuFor(null);
658
+ commit(addFieldToZone(model, col, zone, optsFor(col)));
659
+ }}
660
+ style={{
661
+ display: 'block',
662
+ width: '100%',
663
+ textAlign: 'left',
664
+ border: 'none',
665
+ background: 'transparent',
666
+ cursor: 'pointer',
667
+ font: 'inherit',
668
+ color: 'inherit',
669
+ padding: '6px 8px',
670
+ borderRadius: 4,
671
+ }}
672
+ >
673
+ Add to {ZONE_LABELS[zone]}
674
+ </button>
675
+ ))}
676
+ </div>
677
+ )}
678
+ </div>
679
+ </li>
680
+ );
681
+ })}
682
+ {source.headers.length === 0 && (
683
+ <li style={{ fontSize: 12, opacity: 0.6, padding: '6px 2px' }}>
684
+ No source fields found.
685
+ </li>
686
+ )}
687
+ </ul>
688
+ </section>
689
+
690
+ {/* Drop zones */}
691
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
692
+ <Zone zone="filters" model={model} onDrop={onZoneDrop}>
693
+ {(col, i) => {
694
+ const all = source.distinct(col);
695
+ const allowed = filterAllowedCount(model, i, all.length);
696
+ const stored = model.filters?.[i]?.allowedValues ?? [];
697
+ const isAllowed = (v: string) =>
698
+ stored.length === 0 ? true : stored.includes(v);
699
+ const expanded = expandedFilter === i;
700
+ return (
701
+ <Chip
702
+ key={`f-${i}`}
703
+ label={labelFor(col)}
704
+ zone="filters"
705
+ index={i}
706
+ count={(model.filters ?? []).length}
707
+ onRemove={() => commit(removeFieldFromZone(model, 'filters', i))}
708
+ onMove={(dir) => commit(moveWithinZone(model, 'filters', i, i + dir))}
709
+ >
710
+ <button
711
+ type="button"
712
+ data-testid={`cs-pivot-fields-filter-toggle-${i}`}
713
+ aria-expanded={expanded}
714
+ onClick={() => setExpandedFilter(expanded ? null : i)}
715
+ style={{
716
+ display: 'flex',
717
+ alignItems: 'center',
718
+ gap: 4,
719
+ border: 'none',
720
+ background: 'transparent',
721
+ cursor: 'pointer',
722
+ color: 'inherit',
723
+ font: 'inherit',
724
+ fontSize: 12,
725
+ padding: 0,
726
+ }}
727
+ >
728
+ <Icon name={expanded ? 'expand_less' : 'expand_more'} size={16} />
729
+ <span>
730
+ {allowed} of {all.length} selected
731
+ </span>
732
+ </button>
733
+ {expanded && (
734
+ <div
735
+ data-testid={`cs-pivot-fields-filter-values-${i}`}
736
+ style={{
737
+ maxHeight: 160,
738
+ overflow: 'auto',
739
+ display: 'flex',
740
+ flexDirection: 'column',
741
+ gap: 2,
742
+ }}
743
+ >
744
+ <div style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
745
+ <button
746
+ type="button"
747
+ data-testid={`cs-pivot-fields-filter-all-${i}`}
748
+ onClick={() => commit(setFilterValues(model, i, all))}
749
+ style={linkBtn}
750
+ >
751
+ Select all
752
+ </button>
753
+ <button
754
+ type="button"
755
+ data-testid={`cs-pivot-fields-filter-clear-${i}`}
756
+ onClick={() => commit(setFilterValues(model, i, []))}
757
+ style={linkBtn}
758
+ >
759
+ Clear
760
+ </button>
761
+ </div>
762
+ {all.map((v) => (
763
+ <label
764
+ key={v}
765
+ style={{
766
+ display: 'flex',
767
+ alignItems: 'center',
768
+ gap: 6,
769
+ fontSize: 12,
770
+ }}
771
+ >
772
+ <input
773
+ type="checkbox"
774
+ data-testid={`cs-pivot-fields-filter-${i}-${v || 'blank'}`}
775
+ checked={isAllowed(v)}
776
+ onChange={(e) =>
777
+ commit(toggleFilterValue(model, i, v, e.target.checked, all))
778
+ }
779
+ />
780
+ <span>{v === '' ? '(blank)' : v}</span>
781
+ </label>
782
+ ))}
783
+ </div>
784
+ )}
785
+ </Chip>
786
+ );
787
+ }}
788
+ </Zone>
789
+
790
+ <Zone zone="cols" model={model} onDrop={onZoneDrop}>
791
+ {(col, i) => (
792
+ <Chip
793
+ key={`c-${i}`}
794
+ label={labelFor(col)}
795
+ zone="cols"
796
+ index={i}
797
+ count={model.cols.length}
798
+ onRemove={() => commit(removeFieldFromZone(model, 'cols', i))}
799
+ onMove={(dir) => commit(moveWithinZone(model, 'cols', i, i + dir))}
800
+ />
801
+ )}
802
+ </Zone>
803
+
804
+ <Zone zone="rows" model={model} onDrop={onZoneDrop}>
805
+ {(col, i) => (
806
+ <Chip
807
+ key={`r-${i}`}
808
+ label={labelFor(col)}
809
+ zone="rows"
810
+ index={i}
811
+ count={model.rows.length}
812
+ onRemove={() => commit(removeFieldFromZone(model, 'rows', i))}
813
+ onMove={(dir) => commit(moveWithinZone(model, 'rows', i, i + dir))}
814
+ >
815
+ <select
816
+ aria-label="Group dates by"
817
+ data-testid={`cs-pivot-fields-rows-grouping-${i}`}
818
+ value={model.rows[i]?.grouping ?? 'none'}
819
+ onChange={(e) =>
820
+ commit(updateRowGrouping(model, i, e.target.value as DateGrouping))
821
+ }
822
+ style={selectStyle}
823
+ >
824
+ {(Object.keys(PIVOT_DATE_GROUP_LABELS) as DateGrouping[]).map((g) => (
825
+ <option key={g} value={g}>
826
+ {PIVOT_DATE_GROUP_LABELS[g]}
827
+ </option>
828
+ ))}
829
+ </select>
830
+ </Chip>
831
+ )}
832
+ </Zone>
833
+
834
+ <Zone zone="values" model={model} onDrop={onZoneDrop}>
835
+ {(col, i) => (
836
+ <Chip
837
+ key={`v-${i}`}
838
+ label={`${PIVOT_AGG_LABELS[model.values[i].agg]} of ${labelFor(col)}`}
839
+ zone="values"
840
+ index={i}
841
+ count={model.values.length}
842
+ onRemove={
843
+ hasValues(model) && model.values.length > 1
844
+ ? () => commit(removeFieldFromZone(model, 'values', i))
845
+ : undefined
846
+ }
847
+ onMove={(dir) => commit(moveWithinZone(model, 'values', i, i + dir))}
848
+ >
849
+ <div style={{ display: 'flex', gap: 4 }}>
850
+ <select
851
+ aria-label="Summarize values by"
852
+ data-testid={`cs-pivot-fields-values-agg-${i}`}
853
+ value={model.values[i].agg}
854
+ onChange={(e) =>
855
+ commit(
856
+ updateValueField(model, i, { agg: e.target.value as PivotAggregation }),
857
+ )
858
+ }
859
+ style={selectStyle}
860
+ >
861
+ {(Object.keys(PIVOT_AGG_LABELS) as PivotAggregation[]).map((a) => (
862
+ <option key={a} value={a}>
863
+ {PIVOT_AGG_LABELS[a]}
864
+ </option>
865
+ ))}
866
+ </select>
867
+ <select
868
+ aria-label="Show values as"
869
+ data-testid={`cs-pivot-fields-values-showas-${i}`}
870
+ value={model.values[i].showAs ?? 'normal'}
871
+ onChange={(e) =>
872
+ commit(
873
+ updateValueField(model, i, { showAs: e.target.value as PivotShowAs }),
874
+ )
875
+ }
876
+ style={selectStyle}
877
+ >
878
+ {(Object.keys(PIVOT_SHOW_AS_LABELS) as PivotShowAs[]).map((s) => (
879
+ <option key={s} value={s}>
880
+ {PIVOT_SHOW_AS_LABELS[s]}
881
+ </option>
882
+ ))}
883
+ </select>
884
+ </div>
885
+ </Chip>
886
+ )}
887
+ </Zone>
888
+ </div>
889
+ </>
890
+ )}
891
+ </div>
892
+ </div>
893
+ );
894
+ }
895
+
896
+ const linkBtn: CSSProperties = {
897
+ border: 'none',
898
+ background: 'transparent',
899
+ cursor: 'pointer',
900
+ color: 'var(--cs-chrome-active-fg, #0e7490)',
901
+ font: 'inherit',
902
+ fontSize: 12,
903
+ padding: 0,
904
+ };
905
+
906
+ /* ------------------------------------------------------------------ *
907
+ * Zone — one drop target (Filters / Columns / Rows / Values).
908
+ * ------------------------------------------------------------------ */
909
+
910
+ function Zone({
911
+ zone,
912
+ model,
913
+ children,
914
+ onDrop,
915
+ }: {
916
+ zone: ZoneId;
917
+ model: PivotModel;
918
+ children: (column: number, index: number) => ReactNode;
919
+ onDrop: (zone: ZoneId, raw: string) => void;
920
+ }) {
921
+ const [over, setOver] = useState(false);
922
+ const entries: number[] =
923
+ zone === 'values'
924
+ ? model.values.map((v) => v.column)
925
+ : zone === 'filters'
926
+ ? (model.filters ?? []).map((f) => f.column)
927
+ : model[zone].map((e) => e.column);
928
+
929
+ return (
930
+ <section
931
+ data-testid={`cs-pivot-fields-zone-${zone}`}
932
+ onDragOver={(e) => {
933
+ e.preventDefault();
934
+ e.dataTransfer.dropEffect = 'move';
935
+ if (!over) setOver(true);
936
+ }}
937
+ onDragLeave={(e) => {
938
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setOver(false);
939
+ }}
940
+ onDrop={(e) => {
941
+ e.preventDefault();
942
+ setOver(false);
943
+ onDrop(zone, e.dataTransfer.getData('text/plain'));
944
+ }}
945
+ style={{
946
+ border: over
947
+ ? '1px dashed var(--cs-chrome-active-fg, #0e7490)'
948
+ : '1px dashed var(--cs-chrome-border, #d5d9e0)',
949
+ borderRadius: 8,
950
+ padding: 8,
951
+ background: over ? 'var(--cs-chrome-surface, #eef6f8)' : 'transparent',
952
+ minHeight: 56,
953
+ }}
954
+ >
955
+ <h4 style={{ ...sectionTitle, margin: '0 0 6px' }}>{ZONE_LABELS[zone]}</h4>
956
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
957
+ {entries.length === 0 ? (
958
+ <div style={{ fontSize: 12, opacity: 0.5, padding: '4px 2px' }}>Drop or add fields</div>
959
+ ) : (
960
+ entries.map((col, i) => children(col, i))
961
+ )}
962
+ </div>
963
+ </section>
964
+ );
965
+ }
966
+
967
+ /* ------------------------------------------------------------------ *
968
+ * Chip — a placed field: label, move up/down, remove, extra controls.
969
+ * ------------------------------------------------------------------ */
970
+
971
+ function Chip({
972
+ label,
973
+ zone,
974
+ index,
975
+ count,
976
+ onRemove,
977
+ onMove,
978
+ children,
979
+ }: {
980
+ label: string;
981
+ zone: ZoneId;
982
+ index: number;
983
+ count: number;
984
+ onRemove?: () => void;
985
+ onMove: (dir: -1 | 1) => void;
986
+ children?: ReactNode;
987
+ }) {
988
+ return (
989
+ <div
990
+ data-testid={`cs-pivot-fields-chip-${zone}-${index}`}
991
+ draggable
992
+ onDragStart={(e) => {
993
+ e.dataTransfer.effectAllowed = 'move';
994
+ e.dataTransfer.setData(
995
+ 'text/plain',
996
+ JSON.stringify({ from: 'zone', zone, index } satisfies DragPayload),
997
+ );
998
+ }}
999
+ style={{ ...chipStyle, cursor: 'grab' }}
1000
+ >
1001
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
1002
+ <Icon name="drag_indicator" size={16} style={{ opacity: 0.4 }} />
1003
+ <span
1004
+ title={label}
1005
+ style={{
1006
+ flex: 1,
1007
+ fontSize: 13,
1008
+ overflow: 'hidden',
1009
+ textOverflow: 'ellipsis',
1010
+ whiteSpace: 'nowrap',
1011
+ }}
1012
+ >
1013
+ {label}
1014
+ </span>
1015
+ {index > 0 && (
1016
+ <button
1017
+ type="button"
1018
+ aria-label="Move up"
1019
+ data-testid={`cs-pivot-fields-chip-${zone}-${index}-up`}
1020
+ onClick={() => onMove(-1)}
1021
+ style={iconBtn}
1022
+ >
1023
+ <Icon name="keyboard_arrow_up" size={16} />
1024
+ </button>
1025
+ )}
1026
+ {index < count - 1 && (
1027
+ <button
1028
+ type="button"
1029
+ aria-label="Move down"
1030
+ data-testid={`cs-pivot-fields-chip-${zone}-${index}-down`}
1031
+ onClick={() => onMove(1)}
1032
+ style={iconBtn}
1033
+ >
1034
+ <Icon name="keyboard_arrow_down" size={16} />
1035
+ </button>
1036
+ )}
1037
+ {onRemove && (
1038
+ <button
1039
+ type="button"
1040
+ aria-label={`Remove ${label}`}
1041
+ data-testid={`cs-pivot-fields-chip-${zone}-${index}-remove`}
1042
+ onClick={onRemove}
1043
+ style={iconBtn}
1044
+ >
1045
+ <Icon name="close" size={16} />
1046
+ </button>
1047
+ )}
1048
+ </div>
1049
+ {children}
1050
+ </div>
1051
+ );
1052
+ }