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