logisheets-core 1.14.0 → 1.15.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/field/index.d.ts +0 -26
- package/dist/field/index.js +23 -68
- package/dist/format/index.d.ts +1 -1
- package/dist/ops/index.d.ts +475 -3
- package/dist/ops/index.js +895 -40
- package/dist/permissions/index.d.ts +0 -12
- package/dist/permissions/index.js +6 -9
- package/package.json +2 -2
package/dist/ops/index.js
CHANGED
|
@@ -18,13 +18,131 @@
|
|
|
18
18
|
// host — only the engine-facing operation lives here.
|
|
19
19
|
import { makeTransaction } from '../transaction/index.js';
|
|
20
20
|
import { checkValidations as checkValidationsPure, interpretValidation, } from '../validation/index.js';
|
|
21
|
-
import { checkFieldConstraints as checkFieldConstraintsPure, } from '../field/index.js';
|
|
22
21
|
import { generateFontPayload, generateAlgnmentPayload, generateWrapTextPayload, generateNumFmtPayload, generatePatternFillPayload, generateBorderPayloads, } from '../format/index.js';
|
|
23
22
|
function isErrorMessage(v) {
|
|
24
23
|
return (typeof v === 'object' &&
|
|
25
24
|
v !== null &&
|
|
26
25
|
'msg' in v);
|
|
27
26
|
}
|
|
27
|
+
/** The engine's flat `FieldTypeParts` shape, or undefined for unspecified. */
|
|
28
|
+
function fieldTypeParts(f) {
|
|
29
|
+
if (!f.fieldType || f.fieldType === 'unspecified')
|
|
30
|
+
return undefined;
|
|
31
|
+
return {
|
|
32
|
+
kind: f.fieldType,
|
|
33
|
+
enumSetId: f.enumSetId,
|
|
34
|
+
refSheetId: f.refTarget?.sheetId,
|
|
35
|
+
refBlockId: f.refTarget?.blockId,
|
|
36
|
+
refFieldName: f.refTarget?.fieldName,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `upsertEnumSet` payloads for the sets a block's fields reference.
|
|
41
|
+
*
|
|
42
|
+
* Emitted in the same transaction as the schema bind, so a field declaring
|
|
43
|
+
* `enum{setId}` and the set it names never land apart — the declaration is
|
|
44
|
+
* useless to any other host without the options beside it. A set with no
|
|
45
|
+
* variants is skipped rather than sent: the engine refuses it (it would allow
|
|
46
|
+
* nothing), and failing the whole bind over a half-authored option list is the
|
|
47
|
+
* wrong trade.
|
|
48
|
+
*/
|
|
49
|
+
function enumSetPayloads(fields, sets) {
|
|
50
|
+
if (!sets || sets.length === 0)
|
|
51
|
+
return [];
|
|
52
|
+
const referenced = new Set(fields.map((f) => f.enumSetId).filter((id) => !!id));
|
|
53
|
+
return sets
|
|
54
|
+
.filter((s) => referenced.has(s.id) && s.variants.length > 0)
|
|
55
|
+
.map((s) => ({
|
|
56
|
+
type: 'upsertEnumSet',
|
|
57
|
+
value: {
|
|
58
|
+
id: s.id,
|
|
59
|
+
name: s.name,
|
|
60
|
+
variants: s.variants.map((v) => ({ id: v.id, label: v.label })),
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether the result of `func` over a column is still measured in that
|
|
66
|
+
* column's units, and so should carry its number format.
|
|
67
|
+
*
|
|
68
|
+
* A sum, an average, a minimum or a maximum of a currency column is currency.
|
|
69
|
+
* The counts are not: they answer "how many", so formatting one like the
|
|
70
|
+
* column it counts would print "$3" for three orders. These are the aggregates
|
|
71
|
+
* whose result changes what is being measured.
|
|
72
|
+
*/
|
|
73
|
+
export function aggregateKeepsFormat(func) {
|
|
74
|
+
return func !== 'COUNT' && func !== 'COUNTA';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A pivot recipe as PLAIN data, whatever the caller handed over.
|
|
78
|
+
*
|
|
79
|
+
* A recipe read back off a block arrives through the worker boundary, and what
|
|
80
|
+
* comes out the other side is not necessarily structured-cloneable on the way
|
|
81
|
+
* back in — a host that passes a recipe straight from `BlockInfo.pivot` into a
|
|
82
|
+
* payload gets "could not be cloned" from `postMessage`, at the moment of the
|
|
83
|
+
* write, with nothing in any test to warn it. Rebuilding every field by value
|
|
84
|
+
* makes that impossible to hit.
|
|
85
|
+
*/
|
|
86
|
+
function plainSpec(spec) {
|
|
87
|
+
return {
|
|
88
|
+
rowDim: String(spec.rowDim),
|
|
89
|
+
...(spec.colDim === undefined ? {} : { colDim: String(spec.colDim) }),
|
|
90
|
+
measure: String(spec.measure),
|
|
91
|
+
func: spec.func,
|
|
92
|
+
order: spec.order,
|
|
93
|
+
orderValues: spec.orderValues.map((v) => String(v)),
|
|
94
|
+
filters: spec.filters.map((f) => ({
|
|
95
|
+
field: String(f.field),
|
|
96
|
+
criteria: String(f.criteria),
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A pivot's header is the FIRST line of its block, and its records follow.
|
|
102
|
+
*
|
|
103
|
+
* Named because three places have to agree about it: the geometry (a pivot is
|
|
104
|
+
* one line taller than it has groups), the bind (`headerIdx`), and the writes
|
|
105
|
+
* that fill it. It is first because Excel's `headerRowCount="1"` cannot mean
|
|
106
|
+
* anything else.
|
|
107
|
+
*/
|
|
108
|
+
const PIVOT_HEADER_ROW = 0;
|
|
109
|
+
/** Block-relative row of a pivot's first RECORD, i.e. just after the header. */
|
|
110
|
+
const PIVOT_FIRST_RECORD = 1;
|
|
111
|
+
/**
|
|
112
|
+
* SUM over every field the source declares a number.
|
|
113
|
+
*
|
|
114
|
+
* A default is only possible because the declaration exists: this is what the
|
|
115
|
+
* field-type work in `design/block-field-semantics.md` bought — before it, a
|
|
116
|
+
* caller could only guess from the data.
|
|
117
|
+
*/
|
|
118
|
+
export function defaultAnalysisAggregates(fields) {
|
|
119
|
+
return fields
|
|
120
|
+
.filter((f) => f.isNumber)
|
|
121
|
+
.map((f) => ({ field: f.name, func: 'SUM' }));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One field of a `bindFormSchema` payload.
|
|
125
|
+
*
|
|
126
|
+
* The payload used to take five positionally-aligned arrays (names, renderIds,
|
|
127
|
+
* and one per rule kind), which three call sites below each spelled out by
|
|
128
|
+
* hand. Carrying the declaration too would have made it eight — so the payload
|
|
129
|
+
* became a list of these, and the three call sites became one function.
|
|
130
|
+
*/
|
|
131
|
+
function toSchemaFieldSpec(f) {
|
|
132
|
+
return {
|
|
133
|
+
name: f.name,
|
|
134
|
+
renderId: f.renderId,
|
|
135
|
+
valueFormula: f.valueFormula || undefined,
|
|
136
|
+
validationFormula: f.validationFormula || undefined,
|
|
137
|
+
editabilityFormula: f.editabilityFormula || undefined,
|
|
138
|
+
fieldType: fieldTypeParts(f),
|
|
139
|
+
description: f.description || undefined,
|
|
140
|
+
required: f.required,
|
|
141
|
+
unique: f.unique,
|
|
142
|
+
defaultValue: f.defaultValue || undefined,
|
|
143
|
+
writePolicy: f.writePolicy,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
28
146
|
/**
|
|
29
147
|
* High-level workbook operations bound to one engine {@link Client}.
|
|
30
148
|
* Construct one per workbook and share it across the host.
|
|
@@ -250,6 +368,7 @@ export class WorkbookOps {
|
|
|
250
368
|
colCnt: fields.length,
|
|
251
369
|
},
|
|
252
370
|
},
|
|
371
|
+
...enumSetPayloads(fields, opts.enumSets),
|
|
253
372
|
{
|
|
254
373
|
type: 'bindFormSchema',
|
|
255
374
|
value: {
|
|
@@ -259,11 +378,7 @@ export class WorkbookOps {
|
|
|
259
378
|
fieldFrom: 0,
|
|
260
379
|
row: true,
|
|
261
380
|
keyIdx: keyIdx < 0 ? 0 : keyIdx,
|
|
262
|
-
fields: fields.map(
|
|
263
|
-
renderIds: fields.map((f) => f.renderId),
|
|
264
|
-
fieldFormulas: fields.map((f) => f.valueFormula ?? ''),
|
|
265
|
-
validationFormulas: [],
|
|
266
|
-
editabilityFormulas: [],
|
|
381
|
+
fields: fields.map(toSchemaFieldSpec),
|
|
267
382
|
},
|
|
268
383
|
},
|
|
269
384
|
...fields.map((f) => ({
|
|
@@ -297,6 +412,7 @@ export class WorkbookOps {
|
|
|
297
412
|
colCnt,
|
|
298
413
|
},
|
|
299
414
|
},
|
|
415
|
+
...enumSetPayloads(fields, opts.enumSets),
|
|
300
416
|
{
|
|
301
417
|
type: 'bindFormSchema',
|
|
302
418
|
value: {
|
|
@@ -306,11 +422,7 @@ export class WorkbookOps {
|
|
|
306
422
|
fieldFrom: 0,
|
|
307
423
|
row: true,
|
|
308
424
|
keyIdx: keyIdx < 0 ? 0 : keyIdx,
|
|
309
|
-
fields: fields.map(
|
|
310
|
-
renderIds: fields.map((f) => f.renderId),
|
|
311
|
-
fieldFormulas: fields.map((f) => f.valueFormula ?? ''),
|
|
312
|
-
validationFormulas: [],
|
|
313
|
-
editabilityFormulas: [],
|
|
425
|
+
fields: fields.map(toSchemaFieldSpec),
|
|
314
426
|
},
|
|
315
427
|
},
|
|
316
428
|
...fields.map((f) => ({
|
|
@@ -363,6 +475,7 @@ export class WorkbookOps {
|
|
|
363
475
|
},
|
|
364
476
|
});
|
|
365
477
|
}
|
|
478
|
+
payloads.push(...enumSetPayloads(fields, opts.enumSets));
|
|
366
479
|
payloads.push({
|
|
367
480
|
type: 'bindFormSchema',
|
|
368
481
|
value: {
|
|
@@ -372,11 +485,7 @@ export class WorkbookOps {
|
|
|
372
485
|
fieldFrom: 0,
|
|
373
486
|
row: true,
|
|
374
487
|
keyIdx: keyIdx < 0 ? 0 : keyIdx,
|
|
375
|
-
fields: fields.map(
|
|
376
|
-
renderIds: fields.map((f) => f.renderId),
|
|
377
|
-
fieldFormulas: fields.map((f) => f.valueFormula ?? ''),
|
|
378
|
-
validationFormulas: [],
|
|
379
|
-
editabilityFormulas: [],
|
|
488
|
+
fields: fields.map(toSchemaFieldSpec),
|
|
380
489
|
},
|
|
381
490
|
});
|
|
382
491
|
payloads.push(...fields.map((f) => ({
|
|
@@ -389,6 +498,776 @@ export class WorkbookOps {
|
|
|
389
498
|
})));
|
|
390
499
|
await this.apply(payloads, true);
|
|
391
500
|
}
|
|
501
|
+
/**
|
|
502
|
+
* Rewrite ONE kind of per-field rule on a block, leaving the others alone.
|
|
503
|
+
*
|
|
504
|
+
* `formulas` is one entry per field, in the schema's field order — a rule
|
|
505
|
+
* or `''` for none. The other two rule kinds are sent empty, which the
|
|
506
|
+
* engine reads as "don't touch these", so editing a validation rule cannot
|
|
507
|
+
* clear the value formulas standing next to it.
|
|
508
|
+
*
|
|
509
|
+
* Every existing row is re-materialized from the new rule, so this is also
|
|
510
|
+
* how a rule is removed: pass `''` for that field.
|
|
511
|
+
*/
|
|
512
|
+
async setFieldRules(opts) {
|
|
513
|
+
const { sheetIdx, blockId, kind, formulas } = opts;
|
|
514
|
+
const forKind = (k) => kind === k ? formulas.map((f) => f ?? '') : [];
|
|
515
|
+
await this.apply([
|
|
516
|
+
{
|
|
517
|
+
type: 'upsertFieldFormulas',
|
|
518
|
+
value: {
|
|
519
|
+
sheetIdx,
|
|
520
|
+
blockId,
|
|
521
|
+
fieldFormulas: forKind('value'),
|
|
522
|
+
validationFormulas: forKind('validation'),
|
|
523
|
+
editabilityFormulas: forKind('editability'),
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
], true);
|
|
527
|
+
}
|
|
528
|
+
// ---- analysis blocks ------------------------------------------------
|
|
529
|
+
/**
|
|
530
|
+
* Create the block that analyses `source`: a totals row placed directly
|
|
531
|
+
* below it, declaring what it analyses and how each of its fields
|
|
532
|
+
* aggregates. See `design/block-analysis.md`.
|
|
533
|
+
*
|
|
534
|
+
* **No formula is sent.** The engine generates each aggregate field's
|
|
535
|
+
* formula from the declaration, which is what makes renaming a source
|
|
536
|
+
* field rebuild the total instead of silently zeroing it. A field with no
|
|
537
|
+
* aggregate stays an ordinary cell — that is the label column, and the
|
|
538
|
+
* label is also the key the result is addressed by
|
|
539
|
+
* (`BLOCKREF(refName, label, field)`).
|
|
540
|
+
*
|
|
541
|
+
* One transaction, so it is one undo, and so a reader between two payloads
|
|
542
|
+
* never sees a one-row table it would mistake for a record.
|
|
543
|
+
*
|
|
544
|
+
* Returns what it aggregated, in column order, for the caller to report.
|
|
545
|
+
*/
|
|
546
|
+
async createAnalysisBlock(opts) {
|
|
547
|
+
const { source, blockId, refName, label } = opts;
|
|
548
|
+
const chosen = new Map();
|
|
549
|
+
for (const a of opts.aggregates ??
|
|
550
|
+
defaultAnalysisAggregates(source.fields)) {
|
|
551
|
+
if (!source.fields.some((f) => f.name === a.field)) {
|
|
552
|
+
throw new Error(`Cannot aggregate "${a.field}": the block has no such field.`);
|
|
553
|
+
}
|
|
554
|
+
chosen.set(a.field, a.func);
|
|
555
|
+
}
|
|
556
|
+
if (chosen.size === 0) {
|
|
557
|
+
throw new Error('Nothing to aggregate: no field of this block is declared a ' +
|
|
558
|
+
'number. Give a field the number type first, or choose ' +
|
|
559
|
+
'what to aggregate explicitly.');
|
|
560
|
+
}
|
|
561
|
+
const row = source.rowStart + source.rowCnt;
|
|
562
|
+
const renderIdOf = (i) => `${refName}__agg${i}`;
|
|
563
|
+
await this.apply([
|
|
564
|
+
// Room first, or the block would land on whatever sits below
|
|
565
|
+
// the table. This is also what pushes the pair apart as the
|
|
566
|
+
// source grows, keeping them adjacent.
|
|
567
|
+
{
|
|
568
|
+
type: 'insertRows',
|
|
569
|
+
value: { sheetIdx: source.sheetIdx, start: row, count: 1 },
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
type: 'createBlock',
|
|
573
|
+
value: {
|
|
574
|
+
sheetIdx: source.sheetIdx,
|
|
575
|
+
id: blockId,
|
|
576
|
+
masterRow: row,
|
|
577
|
+
masterCol: source.colStart,
|
|
578
|
+
rowCnt: 1,
|
|
579
|
+
colCnt: source.fields.length,
|
|
580
|
+
analyzes: source.blockId,
|
|
581
|
+
description: `Analysis of "${source.refName}".`,
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
type: 'bindFormSchema',
|
|
586
|
+
value: {
|
|
587
|
+
refName,
|
|
588
|
+
sheetIdx: source.sheetIdx,
|
|
589
|
+
blockId,
|
|
590
|
+
fieldFrom: 0,
|
|
591
|
+
row: true,
|
|
592
|
+
keyIdx: source.keyIdx < 0 ? 0 : source.keyIdx,
|
|
593
|
+
fields: source.fields.map((f, i) => {
|
|
594
|
+
const func = chosen.get(f.name);
|
|
595
|
+
return {
|
|
596
|
+
name: f.name,
|
|
597
|
+
renderId: renderIdOf(i),
|
|
598
|
+
aggFunc: func,
|
|
599
|
+
aggField: func ? f.name : undefined,
|
|
600
|
+
};
|
|
601
|
+
}),
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
// The label, which is the key the result is addressed by.
|
|
605
|
+
{
|
|
606
|
+
type: 'blockInput',
|
|
607
|
+
value: {
|
|
608
|
+
sheetIdx: source.sheetIdx,
|
|
609
|
+
blockId,
|
|
610
|
+
row: 0,
|
|
611
|
+
col: source.keyIdx < 0 ? 0 : source.keyIdx,
|
|
612
|
+
input: label,
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
// Carry each source column's number format across, so a total
|
|
616
|
+
// is formatted like the column it totals rather than as a bare
|
|
617
|
+
// number. A COUNT is the exception — it counts records, not
|
|
618
|
+
// currency — so it is left plain.
|
|
619
|
+
...source.fields.map((f, i) => {
|
|
620
|
+
const func = chosen.get(f.name);
|
|
621
|
+
const keep = func === undefined || aggregateKeepsFormat(func);
|
|
622
|
+
return {
|
|
623
|
+
type: 'upsertFieldRenderInfo',
|
|
624
|
+
value: {
|
|
625
|
+
renderId: renderIdOf(i),
|
|
626
|
+
diyRender: false,
|
|
627
|
+
styleUpdate: {
|
|
628
|
+
setNumFmt: (keep ? f.numFmt : undefined) ?? '',
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}),
|
|
633
|
+
], true);
|
|
634
|
+
return source.fields
|
|
635
|
+
.filter((f) => chosen.has(f.name))
|
|
636
|
+
.map((f) => ({ field: f.name, func: chosen.get(f.name) }));
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Change WHAT an existing analysis block computes — which source fields it
|
|
640
|
+
* aggregates, with which function, and the label its row is addressed by.
|
|
641
|
+
*
|
|
642
|
+
* The shape never changes: an analysis block is one row with one column
|
|
643
|
+
* per source field, so an edit is a re-bind and nothing else. That is the
|
|
644
|
+
* whole difference from {@link editPivot}, where changing the recipe
|
|
645
|
+
* changes how many rows and columns there are.
|
|
646
|
+
*
|
|
647
|
+
* Exists so an analysis can be arrived at in STEPS. The first guess (sum
|
|
648
|
+
* every number) is often nearly right and occasionally wrong in one
|
|
649
|
+
* column, and without this the only remedy was to delete the block and
|
|
650
|
+
* build it again — losing its ref name, and with it every formula pointing
|
|
651
|
+
* at it.
|
|
652
|
+
*/
|
|
653
|
+
async editAnalysisBlock(opts) {
|
|
654
|
+
const { sheetIdx, blockId, refName, source } = opts;
|
|
655
|
+
const chosen = new Map();
|
|
656
|
+
for (const a of opts.aggregates) {
|
|
657
|
+
if (!source.fields.some((f) => f.name === a.field)) {
|
|
658
|
+
throw new Error(`Cannot aggregate "${a.field}": the block has no such field.`);
|
|
659
|
+
}
|
|
660
|
+
chosen.set(a.field, a.func);
|
|
661
|
+
}
|
|
662
|
+
if (chosen.size === 0) {
|
|
663
|
+
throw new Error('Nothing to aggregate: choose at least one field and function.');
|
|
664
|
+
}
|
|
665
|
+
const renderIdOf = (i) => `${refName}__agg${i}`;
|
|
666
|
+
const payloads = [
|
|
667
|
+
{
|
|
668
|
+
type: 'bindFormSchema',
|
|
669
|
+
value: {
|
|
670
|
+
refName,
|
|
671
|
+
sheetIdx,
|
|
672
|
+
blockId,
|
|
673
|
+
fieldFrom: 0,
|
|
674
|
+
row: true,
|
|
675
|
+
keyIdx: source.keyIdx < 0 ? 0 : source.keyIdx,
|
|
676
|
+
fields: source.fields.map((f, i) => {
|
|
677
|
+
const func = chosen.get(f.name);
|
|
678
|
+
return {
|
|
679
|
+
name: f.name,
|
|
680
|
+
renderId: renderIdOf(i),
|
|
681
|
+
aggFunc: func,
|
|
682
|
+
aggField: func ? f.name : undefined,
|
|
683
|
+
};
|
|
684
|
+
}),
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
];
|
|
688
|
+
const keyCol = source.keyIdx < 0 ? 0 : source.keyIdx;
|
|
689
|
+
if (opts.label !== undefined) {
|
|
690
|
+
payloads.push({
|
|
691
|
+
type: 'blockInput',
|
|
692
|
+
value: {
|
|
693
|
+
sheetIdx,
|
|
694
|
+
blockId,
|
|
695
|
+
row: 0,
|
|
696
|
+
col: keyCol,
|
|
697
|
+
input: opts.label,
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
// Blank the columns this edit STOPPED computing.
|
|
702
|
+
//
|
|
703
|
+
// The engine drops the formula on its own — a generated cell with no
|
|
704
|
+
// declaration must not keep one, or it would go on recomputing under a
|
|
705
|
+
// heading that no longer claims it. What it does not do is clear the
|
|
706
|
+
// VALUE the formula last produced: removing a formula normally leaves
|
|
707
|
+
// its value behind, which is right everywhere else and wrong here, and
|
|
708
|
+
// a re-bind gives the container no hook to say so. So the caller says
|
|
709
|
+
// it. Never the key column — that holds the label.
|
|
710
|
+
source.fields.forEach((f, i) => {
|
|
711
|
+
if (i === keyCol || chosen.has(f.name))
|
|
712
|
+
return;
|
|
713
|
+
payloads.push({
|
|
714
|
+
type: 'blockInput',
|
|
715
|
+
value: { sheetIdx, blockId, row: 0, col: i, input: '' },
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
// Formats last, for the same reason as everywhere else: they attach to
|
|
719
|
+
// the render ids the bind declares. A column that stopped being a SUM
|
|
720
|
+
// and became a COUNT stops being currency with it.
|
|
721
|
+
payloads.push(...source.fields.map((f, i) => {
|
|
722
|
+
const func = chosen.get(f.name);
|
|
723
|
+
const keep = func === undefined || aggregateKeepsFormat(func);
|
|
724
|
+
return {
|
|
725
|
+
type: 'upsertFieldRenderInfo',
|
|
726
|
+
value: {
|
|
727
|
+
renderId: renderIdOf(i),
|
|
728
|
+
diyRender: false,
|
|
729
|
+
styleUpdate: {
|
|
730
|
+
setNumFmt: (keep ? f.numFmt : undefined) ?? '',
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}));
|
|
735
|
+
await this.apply(payloads, true);
|
|
736
|
+
return source.fields
|
|
737
|
+
.filter((f) => chosen.has(f.name))
|
|
738
|
+
.map((f) => ({ field: f.name, func: chosen.get(f.name) }));
|
|
739
|
+
}
|
|
740
|
+
// ---- pivots -----------------------------------------------------------
|
|
741
|
+
/**
|
|
742
|
+
* The label row a pivot carries directly above itself, as `cellInput`
|
|
743
|
+
* payloads.
|
|
744
|
+
*
|
|
745
|
+
* A pivot's column names are DATA — they are the column dimension's own
|
|
746
|
+
* values — but they live on the schema, which means the raw sheet shows a
|
|
747
|
+
* grid of numbers with nothing to say what the columns are. Everything
|
|
748
|
+
* that is not our own UI sees it that way: another tool, a person reading
|
|
749
|
+
* the file, and Excel, whose pivot tables require the labels to be in
|
|
750
|
+
* cells. So the pivot writes them there.
|
|
751
|
+
*
|
|
752
|
+
* The row sits OUTSIDE the block, immediately above it. Inside would break
|
|
753
|
+
* what a block is: every row of a block is a record addressed by its key,
|
|
754
|
+
* so a header row would become a record keyed "region" — captured by
|
|
755
|
+
* `#KEY`, counted by the key-uniqueness guard, and aggregated by anything
|
|
756
|
+
* reading the block.
|
|
757
|
+
*
|
|
758
|
+
* Rewritten in full whenever the column set can have changed, because a
|
|
759
|
+
* label left behind from a previous shape is worse than no label: it names
|
|
760
|
+
* a column that is now something else.
|
|
761
|
+
*/
|
|
762
|
+
pivotHeaderRow(opts) {
|
|
763
|
+
// Block-relative, so nothing here depends on where the block sits.
|
|
764
|
+
// The header is one of the block's own lines — the schema says which —
|
|
765
|
+
// so it travels with the block, which a row above it did not.
|
|
766
|
+
//
|
|
767
|
+
// Exactly the columns the block now has, and no attempt to blank the
|
|
768
|
+
// ones a shrink dropped: those cells went with the columns. Reaching
|
|
769
|
+
// past the block's width is not a harmless no-op either — a
|
|
770
|
+
// `blockInput` outside the block fails the whole transaction, which is
|
|
771
|
+
// how the old absolute-coordinate version hid the mistake.
|
|
772
|
+
return opts.fieldNames.map((name, i) => ({
|
|
773
|
+
type: 'blockInput',
|
|
774
|
+
value: {
|
|
775
|
+
sheetIdx: opts.sheetIdx,
|
|
776
|
+
blockId: opts.blockId,
|
|
777
|
+
row: PIVOT_HEADER_ROW,
|
|
778
|
+
col: i,
|
|
779
|
+
input: name,
|
|
780
|
+
},
|
|
781
|
+
}));
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* The number format each of a pivot's columns should carry, in the order
|
|
785
|
+
* the columns are declared (`[key, ...value columns]`).
|
|
786
|
+
*
|
|
787
|
+
* Every column of a pivot aggregates ONE source column, so it is formatted
|
|
788
|
+
* like that column. Which source column differs per kind:
|
|
789
|
+
*
|
|
790
|
+
* - the key column holds the row dimension's own values;
|
|
791
|
+
* - a derived column is `func(measure)` from the recipe;
|
|
792
|
+
* - a declared column (a row total, a second measure) names its own, and
|
|
793
|
+
* falls back to the recipe's for whatever it leaves out.
|
|
794
|
+
*
|
|
795
|
+
* A COUNT column is deliberately plain — see {@link aggregateKeepsFormat}.
|
|
796
|
+
*
|
|
797
|
+
* Returned as `''` rather than `undefined` for "no format", because the
|
|
798
|
+
* payload is also how a format is CLEARED, and a refresh re-states every
|
|
799
|
+
* column: `refreshPivot` reassigns render ids by position, so a column
|
|
800
|
+
* that appears shifts the ids after it, and only restating all of them
|
|
801
|
+
* keeps each format on the column it belongs to.
|
|
802
|
+
*/
|
|
803
|
+
pivotColumnFormats(opts) {
|
|
804
|
+
const { fieldNames, numFmts } = opts;
|
|
805
|
+
const fmt = (field, func) => (aggregateKeepsFormat(func) ? numFmts[field] : undefined) ?? '';
|
|
806
|
+
return fieldNames.map((name, i) => {
|
|
807
|
+
// Field 0 is the key column: the row dimension's values, named
|
|
808
|
+
// after the dimension itself.
|
|
809
|
+
if (i === 0)
|
|
810
|
+
return numFmts[name] ?? '';
|
|
811
|
+
const d = opts.declared?.(name);
|
|
812
|
+
if (d)
|
|
813
|
+
return fmt(d.measure ?? opts.measure, d.func ?? opts.func);
|
|
814
|
+
return fmt(opts.measure, opts.func);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Create a pivot of `source`: a cross-tab whose rows are the distinct
|
|
819
|
+
* values of `rowDim`, whose columns are the distinct values of `colDim`,
|
|
820
|
+
* and whose cells are `func` over `measure`.
|
|
821
|
+
*
|
|
822
|
+
* **One transaction**, so it is one undo — which is why it asks the engine
|
|
823
|
+
* for the shape BEFORE creating the block (`pivotPlanFor`). Creating first
|
|
824
|
+
* and reshaping after would leave an empty declared pivot as an
|
|
825
|
+
* intermediate state and take two undos to remove.
|
|
826
|
+
*
|
|
827
|
+
* No formula is sent. The engine generates every cell from the recipe plus
|
|
828
|
+
* the cell's own row key and field name, which is what makes renaming a
|
|
829
|
+
* source field rebuild the pivot instead of breaking it.
|
|
830
|
+
*
|
|
831
|
+
* Returns the shape it created, for the caller to report.
|
|
832
|
+
*/
|
|
833
|
+
async createPivot(opts) {
|
|
834
|
+
const { source, blockId, refName, rowDim, colDim, measure, func } = opts;
|
|
835
|
+
const spec = plainSpec({
|
|
836
|
+
rowDim,
|
|
837
|
+
colDim,
|
|
838
|
+
measure,
|
|
839
|
+
func,
|
|
840
|
+
order: opts.order ?? 'ascending',
|
|
841
|
+
// Always sent, even empty: the plan and the cells must agree
|
|
842
|
+
// about what is counted, and an omitted list on one side only
|
|
843
|
+
// would be exactly that disagreement.
|
|
844
|
+
orderValues: opts.orderValues ?? [],
|
|
845
|
+
filters: opts.filters ?? [],
|
|
846
|
+
});
|
|
847
|
+
const plan = await this.client.pivotPlanFor({
|
|
848
|
+
sheetIdx: source.sheetIdx,
|
|
849
|
+
sourceBlock: source.blockId,
|
|
850
|
+
spec,
|
|
851
|
+
});
|
|
852
|
+
if (isErrorMessage(plan)) {
|
|
853
|
+
throw new Error(plan.msg);
|
|
854
|
+
}
|
|
855
|
+
if (plan.keys.length === 0) {
|
|
856
|
+
throw new Error(`Nothing to pivot: no record of "${source.refName}" has a value ` +
|
|
857
|
+
`for "${rowDim}", so the pivot would have no rows.`);
|
|
858
|
+
}
|
|
859
|
+
const extra = opts.extraColumns ?? [];
|
|
860
|
+
const valueFields = [
|
|
861
|
+
...(colDim ? plan.fields : [opts.valueColumn ?? measure]),
|
|
862
|
+
...extra.map((c) => c.name),
|
|
863
|
+
];
|
|
864
|
+
// The block starts directly below the source and OWNS its header
|
|
865
|
+
// line, so it is one row taller than it has groups.
|
|
866
|
+
const row = source.rowStart + source.rowCnt;
|
|
867
|
+
// The key column is named after the row dimension: it holds that
|
|
868
|
+
// dimension's values, and the name is what a reader sees.
|
|
869
|
+
const fieldNames = [rowDim, ...valueFields];
|
|
870
|
+
const rowCnt = plan.keys.length + 1;
|
|
871
|
+
await this.apply([
|
|
872
|
+
// Room first, so the pivot does not land on whatever sits
|
|
873
|
+
// below the table.
|
|
874
|
+
{
|
|
875
|
+
type: 'insertRows',
|
|
876
|
+
value: {
|
|
877
|
+
sheetIdx: source.sheetIdx,
|
|
878
|
+
start: row,
|
|
879
|
+
count: rowCnt,
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
type: 'createBlock',
|
|
884
|
+
value: {
|
|
885
|
+
sheetIdx: source.sheetIdx,
|
|
886
|
+
id: blockId,
|
|
887
|
+
masterRow: row,
|
|
888
|
+
masterCol: source.colStart,
|
|
889
|
+
rowCnt,
|
|
890
|
+
colCnt: fieldNames.length,
|
|
891
|
+
analyzes: source.blockId,
|
|
892
|
+
// Declared as it is created, so it is never briefly a
|
|
893
|
+
// stray table a reader would take for records.
|
|
894
|
+
pivot: spec,
|
|
895
|
+
description: `Pivot of "${source.refName}": rows = ${rowDim}` +
|
|
896
|
+
(colDim ? `, columns = ${colDim}` : '') +
|
|
897
|
+
`, ${func} of ${measure}.`,
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
// The header names, and the keys, BOTH before the bind.
|
|
901
|
+
// `#KEY` is captured when the bind materializes each row, so a
|
|
902
|
+
// key written afterwards leaves that row filtering on "" — a
|
|
903
|
+
// whole grid of zeros, no error.
|
|
904
|
+
...this.pivotHeaderRow({
|
|
905
|
+
sheetIdx: source.sheetIdx,
|
|
906
|
+
blockId,
|
|
907
|
+
fieldNames,
|
|
908
|
+
}),
|
|
909
|
+
...plan.keys.map((key, i) => ({
|
|
910
|
+
type: 'blockInput',
|
|
911
|
+
value: {
|
|
912
|
+
sheetIdx: source.sheetIdx,
|
|
913
|
+
blockId,
|
|
914
|
+
row: PIVOT_FIRST_RECORD + i,
|
|
915
|
+
col: 0,
|
|
916
|
+
input: key,
|
|
917
|
+
},
|
|
918
|
+
})),
|
|
919
|
+
{
|
|
920
|
+
type: 'bindFormSchema',
|
|
921
|
+
value: {
|
|
922
|
+
refName,
|
|
923
|
+
sheetIdx: source.sheetIdx,
|
|
924
|
+
blockId,
|
|
925
|
+
fieldFrom: 0,
|
|
926
|
+
keyIdx: 0,
|
|
927
|
+
row: true,
|
|
928
|
+
// The first line holds the field names, not a record.
|
|
929
|
+
headerIdx: PIVOT_HEADER_ROW,
|
|
930
|
+
// Field names ARE the column dimension's values.
|
|
931
|
+
// Nothing per-field is declared; the engine derives
|
|
932
|
+
// every cell from the recipe.
|
|
933
|
+
fields: fieldNames.map((name, i) => {
|
|
934
|
+
const declared = extra.find((c) => c.name === name);
|
|
935
|
+
return {
|
|
936
|
+
name,
|
|
937
|
+
renderId: `${refName}__p${i}`,
|
|
938
|
+
// Only a DECLARED column carries these; a
|
|
939
|
+
// derived one says nothing and the engine
|
|
940
|
+
// reads its name as the dimension value.
|
|
941
|
+
...(declared
|
|
942
|
+
? {
|
|
943
|
+
pivotColValue: declared.colValue ?? '*',
|
|
944
|
+
pivotMeasure: declared.measure,
|
|
945
|
+
pivotFunc: declared.func,
|
|
946
|
+
}
|
|
947
|
+
: {}),
|
|
948
|
+
};
|
|
949
|
+
}),
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
// After the bind, which is what declares the render ids these
|
|
953
|
+
// attach to. A pivot of a currency column reads as currency.
|
|
954
|
+
...this.pivotColumnFormats({
|
|
955
|
+
fieldNames,
|
|
956
|
+
measure,
|
|
957
|
+
func,
|
|
958
|
+
numFmts: source.numFmts ?? {},
|
|
959
|
+
declared: (name) => extra.find((c) => c.name === name),
|
|
960
|
+
}).map((numFmt, i) => ({
|
|
961
|
+
type: 'upsertFieldRenderInfo',
|
|
962
|
+
value: {
|
|
963
|
+
renderId: `${refName}__p${i}`,
|
|
964
|
+
diyRender: false,
|
|
965
|
+
styleUpdate: { setNumFmt: numFmt },
|
|
966
|
+
},
|
|
967
|
+
})),
|
|
968
|
+
], true);
|
|
969
|
+
return {
|
|
970
|
+
keys: [...plan.keys],
|
|
971
|
+
fields: valueFields,
|
|
972
|
+
unassignedRecords: plan.unassignedRecords,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Bring a pivot's SHAPE back in line with its source: add rows for groups
|
|
977
|
+
* that appeared, drop rows for groups that are gone, same for columns.
|
|
978
|
+
*
|
|
979
|
+
* Its numbers were never stale — they are live formulas. Only the set of
|
|
980
|
+
* rows and columns needs this, because no formula can add a row.
|
|
981
|
+
*
|
|
982
|
+
* One transaction, and the payload order is not negotiable
|
|
983
|
+
* (`design/block-pivot.md` §6): grow, write the keys, bind, shrink. Keys
|
|
984
|
+
* before the bind because `#KEY` is captured at materialization; grow
|
|
985
|
+
* before the bind because a field cannot bind to a column that does not
|
|
986
|
+
* exist; shrink after, so nothing is left bound to a vanishing column.
|
|
987
|
+
*
|
|
988
|
+
* Returns what changed, or `null` when the pivot was already current — so
|
|
989
|
+
* a caller can say "nothing to do" instead of reporting an empty refresh.
|
|
990
|
+
*/
|
|
991
|
+
async refreshPivot(opts) {
|
|
992
|
+
// No sheet coordinates: the keys and the header are written
|
|
993
|
+
// block-relative now, so a caller cannot get them wrong. It used to
|
|
994
|
+
// take `rowStart`, and a stale one wrote the keys outside the block —
|
|
995
|
+
// silently, because a key nobody reads just leaves the row filtering
|
|
996
|
+
// on the value it already had.
|
|
997
|
+
const { sheetIdx, blockId, refName, keyField } = opts;
|
|
998
|
+
const plan = await this.client.pivotPlan({ sheetIdx, blockId });
|
|
999
|
+
if (isErrorMessage(plan)) {
|
|
1000
|
+
throw new Error(plan.msg);
|
|
1001
|
+
}
|
|
1002
|
+
if (!plan.isStale)
|
|
1003
|
+
return null;
|
|
1004
|
+
// A grouped pivot plans no columns, so it keeps the ones it has.
|
|
1005
|
+
const fields = plan.fields.length > 0 ? [...plan.fields] : [...plan.currentFields];
|
|
1006
|
+
const fieldNames = [keyField, ...fields];
|
|
1007
|
+
// The block owns its header line, so it is one row taller than it has
|
|
1008
|
+
// groups.
|
|
1009
|
+
const newRowCnt = plan.keys.length + 1;
|
|
1010
|
+
const newColCnt = fieldNames.length;
|
|
1011
|
+
const payloads = [];
|
|
1012
|
+
// ONE resize, to the final size, BEFORE the bind — in both directions.
|
|
1013
|
+
//
|
|
1014
|
+
// Growing first is obvious: a field cannot bind to a column that does
|
|
1015
|
+
// not exist. Shrinking first is the part that cost something to learn:
|
|
1016
|
+
// a `ResizeBlock` sent AFTER a bind leaves the generated formulas
|
|
1017
|
+
// uncalculated, so a refresh that dropped a group left every surviving
|
|
1018
|
+
// row BLANK — the keys were right and the numbers were gone. It is the
|
|
1019
|
+
// same hazard as the no-op resize in
|
|
1020
|
+
// `refreshing_a_pivot_with_a_trailing_no_op_resize_loses_the_new_row`,
|
|
1021
|
+
// and the rule that covers both is: never resize a block after binding
|
|
1022
|
+
// it. Nothing is orphaned, because the bind that follows states the
|
|
1023
|
+
// surviving fields and only those.
|
|
1024
|
+
if (newRowCnt !== plan.currentKeys.length + 1 ||
|
|
1025
|
+
newColCnt !== plan.currentFields.length + 1) {
|
|
1026
|
+
payloads.push({
|
|
1027
|
+
type: 'resizeBlock',
|
|
1028
|
+
value: { sheetIdx, id: blockId, newRowCnt, newColCnt },
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
payloads.push(
|
|
1032
|
+
// The labels, in full: a refresh can add or drop columns, and a
|
|
1033
|
+
// label left over from the old shape names a column that is now
|
|
1034
|
+
// something else.
|
|
1035
|
+
...this.pivotHeaderRow({
|
|
1036
|
+
sheetIdx,
|
|
1037
|
+
blockId,
|
|
1038
|
+
fieldNames,
|
|
1039
|
+
}), ...plan.keys.map((key, i) => ({
|
|
1040
|
+
type: 'blockInput',
|
|
1041
|
+
value: {
|
|
1042
|
+
sheetIdx,
|
|
1043
|
+
blockId,
|
|
1044
|
+
row: PIVOT_FIRST_RECORD + i,
|
|
1045
|
+
col: 0,
|
|
1046
|
+
input: key,
|
|
1047
|
+
},
|
|
1048
|
+
})), {
|
|
1049
|
+
type: 'bindFormSchema',
|
|
1050
|
+
value: {
|
|
1051
|
+
refName,
|
|
1052
|
+
sheetIdx,
|
|
1053
|
+
blockId,
|
|
1054
|
+
fieldFrom: 0,
|
|
1055
|
+
keyIdx: 0,
|
|
1056
|
+
row: true,
|
|
1057
|
+
// Restated: the bind states the whole interpretation, so
|
|
1058
|
+
// omitting this would turn the header line into a record.
|
|
1059
|
+
headerIdx: PIVOT_HEADER_ROW,
|
|
1060
|
+
fields: fieldNames.map((name, i) => {
|
|
1061
|
+
const was = opts.currentFields?.find((f) => f.field === name);
|
|
1062
|
+
return {
|
|
1063
|
+
name,
|
|
1064
|
+
renderId: `${refName}__p${i}`,
|
|
1065
|
+
// Restated verbatim. A declared column is not
|
|
1066
|
+
// derived from the data, so a refresh has no
|
|
1067
|
+
// business reinterpreting it.
|
|
1068
|
+
...(was?.pivotColValue !== undefined
|
|
1069
|
+
? {
|
|
1070
|
+
pivotColValue: was.pivotColValue,
|
|
1071
|
+
pivotMeasure: was.pivotMeasure,
|
|
1072
|
+
pivotFunc: was.pivotFunc,
|
|
1073
|
+
}
|
|
1074
|
+
: {}),
|
|
1075
|
+
};
|
|
1076
|
+
}),
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
// EVERY column is restated: render ids are assigned by position,
|
|
1080
|
+
// so a column that appears shifts the ids after it and each format
|
|
1081
|
+
// would otherwise stay behind on the wrong column.
|
|
1082
|
+
if (opts.formats) {
|
|
1083
|
+
payloads.push(...this.pivotColumnFormats({
|
|
1084
|
+
fieldNames,
|
|
1085
|
+
measure: opts.formats.measure,
|
|
1086
|
+
func: opts.formats.func,
|
|
1087
|
+
numFmts: opts.formats.numFmts,
|
|
1088
|
+
declared: (name) => {
|
|
1089
|
+
const was = opts.currentFields?.find((f) => f.field === name);
|
|
1090
|
+
return was?.pivotColValue !== undefined
|
|
1091
|
+
? {
|
|
1092
|
+
measure: was.pivotMeasure,
|
|
1093
|
+
func: was.pivotFunc,
|
|
1094
|
+
}
|
|
1095
|
+
: undefined;
|
|
1096
|
+
},
|
|
1097
|
+
}).map((numFmt, i) => ({
|
|
1098
|
+
type: 'upsertFieldRenderInfo',
|
|
1099
|
+
value: {
|
|
1100
|
+
renderId: `${refName}__p${i}`,
|
|
1101
|
+
diyRender: false,
|
|
1102
|
+
styleUpdate: { setNumFmt: numFmt },
|
|
1103
|
+
},
|
|
1104
|
+
})));
|
|
1105
|
+
}
|
|
1106
|
+
await this.apply(payloads, true);
|
|
1107
|
+
return {
|
|
1108
|
+
addedKeys: [...plan.missingKeys],
|
|
1109
|
+
removedKeys: [...plan.extraKeys],
|
|
1110
|
+
addedFields: [...plan.missingFields],
|
|
1111
|
+
removedFields: [...plan.extraFields],
|
|
1112
|
+
unassignedRecords: plan.unassignedRecords,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Change a pivot's RECIPE — what it groups by, what it measures, how, in
|
|
1117
|
+
* what order, over which records — and reshape it to match, in one
|
|
1118
|
+
* transaction.
|
|
1119
|
+
*
|
|
1120
|
+
* A recipe is not editable in place by hand: a pivot's numbers are
|
|
1121
|
+
* generated from it, its column names ARE the column dimension's values,
|
|
1122
|
+
* and its key column holds the row dimension's. Changing any of those by
|
|
1123
|
+
* typing produces a table that says one thing and computes another —
|
|
1124
|
+
* editing a row label does not re-aim the row, it relabels a group.
|
|
1125
|
+
*
|
|
1126
|
+
* Deliberately does NOT ask for the current plan. The commonest reason to
|
|
1127
|
+
* edit is that the recipe has stopped resolving (a source field renamed
|
|
1128
|
+
* out from under it, say), and planning the OLD recipe would fail exactly
|
|
1129
|
+
* then. The caller states the block's present size instead, which it can
|
|
1130
|
+
* always see.
|
|
1131
|
+
*
|
|
1132
|
+
* Payload order, for the same reasons as §6 with one addition: the RECIPE
|
|
1133
|
+
* goes before the bind, because the bind is what regenerates every cell
|
|
1134
|
+
* from it — set it after and the block re-materializes from the old one.
|
|
1135
|
+
*
|
|
1136
|
+
* grow (if growing) → recipe → keys → bind → shrink (only if shrinking)
|
|
1137
|
+
*
|
|
1138
|
+
* Returns the shape it produced.
|
|
1139
|
+
*/
|
|
1140
|
+
async editPivot(opts) {
|
|
1141
|
+
const { sheetIdx, blockId, refName, source, rowDim, colDim, measure, func, } = opts;
|
|
1142
|
+
// Rebuilt by value: an edit's recipe usually comes from the block
|
|
1143
|
+
// itself, and passing that back through the worker unchanged fails to
|
|
1144
|
+
// clone.
|
|
1145
|
+
const spec = plainSpec({
|
|
1146
|
+
rowDim,
|
|
1147
|
+
colDim,
|
|
1148
|
+
measure,
|
|
1149
|
+
func,
|
|
1150
|
+
order: opts.order ?? 'ascending',
|
|
1151
|
+
orderValues: opts.orderValues ?? [],
|
|
1152
|
+
filters: opts.filters ?? [],
|
|
1153
|
+
});
|
|
1154
|
+
const plan = await this.client.pivotPlanFor({
|
|
1155
|
+
sheetIdx,
|
|
1156
|
+
sourceBlock: source.blockId,
|
|
1157
|
+
spec,
|
|
1158
|
+
});
|
|
1159
|
+
if (isErrorMessage(plan)) {
|
|
1160
|
+
throw new Error(plan.msg);
|
|
1161
|
+
}
|
|
1162
|
+
if (plan.keys.length === 0) {
|
|
1163
|
+
throw new Error(`Nothing to pivot: no record of "${source.refName}" has a value ` +
|
|
1164
|
+
`for "${rowDim}", so the pivot would have no rows.`);
|
|
1165
|
+
}
|
|
1166
|
+
const extra = opts.extraColumns ?? [];
|
|
1167
|
+
const valueFields = [
|
|
1168
|
+
...(colDim ? plan.fields : [opts.valueColumn ?? measure]),
|
|
1169
|
+
...extra.map((c) => c.name),
|
|
1170
|
+
];
|
|
1171
|
+
// The key column is named after the row dimension, which the edit may
|
|
1172
|
+
// have just changed.
|
|
1173
|
+
const fieldNames = [rowDim, ...valueFields];
|
|
1174
|
+
// One line taller than it has groups: the block owns its header.
|
|
1175
|
+
const newRowCnt = plan.keys.length + 1;
|
|
1176
|
+
const newColCnt = fieldNames.length;
|
|
1177
|
+
const payloads = [];
|
|
1178
|
+
// ONE resize, to the final size, BEFORE the bind — in both directions.
|
|
1179
|
+
//
|
|
1180
|
+
// A refresh has to grow before and shrink after, because it keeps the
|
|
1181
|
+
// old rows' keys and only adds to them. An edit rewrites every key, so
|
|
1182
|
+
// it can take the block to its final size first and then bind onto it
|
|
1183
|
+
// — which is the safer order: a `ResizeBlock` sent AFTER a bind leaves
|
|
1184
|
+
// the generated formulas uncalculated, showing the value each cell had
|
|
1185
|
+
// before the edit under its new formula. Resizing first also means the
|
|
1186
|
+
// rows a shrink drops are gone before the new keys are written, so a
|
|
1187
|
+
// key that already exists further down is not briefly a duplicate.
|
|
1188
|
+
if (newRowCnt !== opts.currentRowCnt ||
|
|
1189
|
+
newColCnt !== opts.currentColCnt) {
|
|
1190
|
+
payloads.push({
|
|
1191
|
+
type: 'resizeBlock',
|
|
1192
|
+
value: { sheetIdx, id: blockId, newRowCnt, newColCnt },
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
payloads.push(
|
|
1196
|
+
// The labels, in full — an edit can change the whole column set.
|
|
1197
|
+
...this.pivotHeaderRow({
|
|
1198
|
+
sheetIdx,
|
|
1199
|
+
blockId,
|
|
1200
|
+
fieldNames,
|
|
1201
|
+
}),
|
|
1202
|
+
// The new recipe, BEFORE the bind that regenerates from it.
|
|
1203
|
+
{
|
|
1204
|
+
type: 'setBlockAnalyzes',
|
|
1205
|
+
value: {
|
|
1206
|
+
sheetIdx,
|
|
1207
|
+
blockId,
|
|
1208
|
+
analyzes: source.blockId,
|
|
1209
|
+
pivot: spec,
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
// Keys before the bind: `#KEY` is captured at materialization.
|
|
1213
|
+
...plan.keys.map((key, i) => ({
|
|
1214
|
+
type: 'blockInput',
|
|
1215
|
+
value: {
|
|
1216
|
+
sheetIdx,
|
|
1217
|
+
blockId,
|
|
1218
|
+
row: PIVOT_FIRST_RECORD + i,
|
|
1219
|
+
col: 0,
|
|
1220
|
+
input: key,
|
|
1221
|
+
},
|
|
1222
|
+
})), {
|
|
1223
|
+
type: 'bindFormSchema',
|
|
1224
|
+
value: {
|
|
1225
|
+
refName,
|
|
1226
|
+
sheetIdx,
|
|
1227
|
+
blockId,
|
|
1228
|
+
fieldFrom: 0,
|
|
1229
|
+
keyIdx: 0,
|
|
1230
|
+
row: true,
|
|
1231
|
+
// Restated: the bind states the whole interpretation, so
|
|
1232
|
+
// omitting this would turn the header line into a record.
|
|
1233
|
+
headerIdx: PIVOT_HEADER_ROW,
|
|
1234
|
+
fields: fieldNames.map((name, i) => {
|
|
1235
|
+
const declared = extra.find((c) => c.name === name);
|
|
1236
|
+
return {
|
|
1237
|
+
name,
|
|
1238
|
+
renderId: `${refName}__p${i}`,
|
|
1239
|
+
...(declared
|
|
1240
|
+
? {
|
|
1241
|
+
pivotColValue: declared.colValue ?? '*',
|
|
1242
|
+
pivotMeasure: declared.measure,
|
|
1243
|
+
pivotFunc: declared.func,
|
|
1244
|
+
}
|
|
1245
|
+
: {}),
|
|
1246
|
+
};
|
|
1247
|
+
}),
|
|
1248
|
+
},
|
|
1249
|
+
});
|
|
1250
|
+
payloads.push(...this.pivotColumnFormats({
|
|
1251
|
+
fieldNames,
|
|
1252
|
+
measure,
|
|
1253
|
+
func,
|
|
1254
|
+
numFmts: source.numFmts ?? {},
|
|
1255
|
+
declared: (name) => extra.find((c) => c.name === name),
|
|
1256
|
+
}).map((numFmt, i) => ({
|
|
1257
|
+
type: 'upsertFieldRenderInfo',
|
|
1258
|
+
value: {
|
|
1259
|
+
renderId: `${refName}__p${i}`,
|
|
1260
|
+
diyRender: false,
|
|
1261
|
+
styleUpdate: { setNumFmt: numFmt },
|
|
1262
|
+
},
|
|
1263
|
+
})));
|
|
1264
|
+
await this.apply(payloads, true);
|
|
1265
|
+
return {
|
|
1266
|
+
keys: [...plan.keys],
|
|
1267
|
+
fields: valueFields,
|
|
1268
|
+
unassignedRecords: plan.unassignedRecords,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
392
1271
|
// ---- generic / temp-branch -----------------------------------------
|
|
393
1272
|
/**
|
|
394
1273
|
* Apply a caller-built payload list as one transaction (at the host's
|
|
@@ -521,28 +1400,4 @@ export class WorkbookOps {
|
|
|
521
1400
|
}
|
|
522
1401
|
return checkValidationsPure(rules, (_sheetIdx, formula) => values.get(formula));
|
|
523
1402
|
}
|
|
524
|
-
/** Check required / unique / membership field constraints. */
|
|
525
|
-
async checkFieldConstraints(columns) {
|
|
526
|
-
const values = new Map();
|
|
527
|
-
for (const { cells } of columns) {
|
|
528
|
-
for (const c of cells) {
|
|
529
|
-
const key = `${c.sheetIdx}:${c.row}:${c.col}`;
|
|
530
|
-
if (values.has(key))
|
|
531
|
-
continue;
|
|
532
|
-
const v = await this.client.getValue({
|
|
533
|
-
sheetIdx: c.sheetIdx,
|
|
534
|
-
row: c.row,
|
|
535
|
-
col: c.col,
|
|
536
|
-
});
|
|
537
|
-
if (isErrorMessage(v)) {
|
|
538
|
-
throw new Error('Failed to read cell value: ' + v.msg);
|
|
539
|
-
}
|
|
540
|
-
values.set(key, v);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
return checkFieldConstraintsPure(columns, (sheetIdx, row, col) => {
|
|
544
|
-
const v = values.get(`${sheetIdx}:${row}:${col}`);
|
|
545
|
-
return (v ?? 'empty');
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
1403
|
}
|