@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.
- package/dist/{api-DJhuCjhn.d.cts → api-cyO4EA4_.d.cts} +1 -1
- package/dist/{api-DJhuCjhn.d.ts → api-cyO4EA4_.d.ts} +1 -1
- package/dist/{attachCollab-BmCRT4V_.d.ts → attachCollab-BT4fVzxP.d.ts} +1 -1
- package/dist/{attachCollab-DIUQCCrq.d.cts → attachCollab-DHjb5XYK.d.cts} +1 -1
- package/dist/chrome.cjs +60 -3
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.d.cts +3 -3
- package/dist/chrome.d.ts +3 -3
- package/dist/chrome.js +60 -3
- package/dist/chrome.js.map +1 -1
- package/dist/collab.d.cts +2 -2
- package/dist/collab.d.ts +2 -2
- package/dist/embed/embed-runtime.js +205 -161
- package/dist/{extensions-DZnqTEN2.d.ts → extensions-Cni7mbEm.d.ts} +1 -1
- package/dist/{extensions-FFqdLpk_.d.cts → extensions-DMmPnIYB.d.cts} +1 -1
- package/dist/index.cjs +6424 -1559
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6409 -1530
- package/dist/index.js.map +1 -1
- package/dist/sheets.cjs +5426 -561
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.d.cts +5 -5
- package/dist/sheets.d.ts +5 -5
- package/dist/sheets.js +5415 -536
- package/dist/sheets.js.map +1 -1
- package/package.json +8 -7
- package/src/charts/ChartContextMenu.tsx +264 -0
- package/src/charts/ChartLayer.tsx +333 -0
- package/src/charts/ChartOverlay.tsx +293 -0
- package/src/charts/ChartsPanel.tsx +232 -0
- package/src/charts/FormatChartDialog.tsx +419 -0
- package/src/charts/InsertChartDialog.tsx +478 -0
- package/src/charts/build-option.ts +476 -0
- package/src/charts/charts-context.tsx +205 -0
- package/src/charts/echarts-init.ts +58 -0
- package/src/charts/hit-test.ts +130 -0
- package/src/charts/insert-chart.ts +106 -0
- package/src/charts/naming.ts +38 -0
- package/src/charts/render-to-png.ts +117 -0
- package/src/charts/resources.ts +108 -0
- package/src/charts/types.ts +239 -0
- package/src/charts/univer-dom.ts +102 -0
- package/src/chrome/CommentsPanel.tsx +418 -0
- package/src/chrome/HistoryPanel.tsx +304 -0
- package/src/chrome/InsertPivotDialog.tsx +74 -2
- package/src/chrome/PanelHost.tsx +56 -0
- package/src/chrome/PanelRail.tsx +90 -0
- package/src/chrome/PivotFieldsPanel.tsx +1021 -0
- package/src/chrome/TablesPanel.tsx +283 -0
- package/src/chrome/Toolbar.tsx +7 -1
- package/src/chrome/panel-context.tsx +55 -0
- package/src/chrome/panel-registry.ts +48 -0
- package/src/chrome/panel-shell.tsx +151 -0
- package/src/pivots/apply.ts +139 -0
- package/src/pivots/compute.ts +604 -0
- package/src/pivots/fields-model.ts +267 -0
- package/src/pivots/types.ts +137 -0
- package/src/sheets/CasualSheets.tsx +61 -35
- 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
|
+
}
|