@svgrid/enterprise 1.0.1
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/LICENSE +97 -0
- package/README.md +37 -0
- package/package.json +72 -0
- package/src/ai.test.ts +522 -0
- package/src/ai.ts +782 -0
- package/src/export.ts +512 -0
- package/src/import.test.ts +415 -0
- package/src/import.ts +648 -0
- package/src/index.ts +88 -0
- package/src/install.ts +114 -0
- package/src/license.ts +90 -0
- package/src/pdfmake-shims.d.ts +22 -0
- package/src/pivot.test.ts +308 -0
- package/src/pivot.ts +549 -0
- package/src/print.ts +127 -0
- package/src/revoked.ts +49 -0
- package/src/smart-shim.ts +105 -0
- package/src/smart.export.d.ts +5 -0
- package/src/smart.export.js +7 -0
- package/src/staged-editing.ts +0 -0
- package/src/upgrade-prompt.test.ts +71 -0
- package/src/upgrade-prompt.ts +148 -0
- package/src/watermark.test.ts +97 -0
- package/src/watermark.ts +156 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export {
|
|
2
|
+
setLicenseKey,
|
|
3
|
+
clearLicenseKey,
|
|
4
|
+
getLicenseKey,
|
|
5
|
+
isLicenseKeySet,
|
|
6
|
+
hasValidLicense,
|
|
7
|
+
assertProLicensed,
|
|
8
|
+
} from './license'
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
exportGrid,
|
|
12
|
+
type ExportFormat,
|
|
13
|
+
type ExportOptions,
|
|
14
|
+
type ExportColumn,
|
|
15
|
+
type ExportCellStyle,
|
|
16
|
+
type ExportStyles,
|
|
17
|
+
type ExportHeaderFooterLine,
|
|
18
|
+
type ExportSheet,
|
|
19
|
+
} from './export'
|
|
20
|
+
export { printGrid, type PrintOptions } from './print'
|
|
21
|
+
export {
|
|
22
|
+
importData,
|
|
23
|
+
type ImportFormat,
|
|
24
|
+
type ImportFieldType,
|
|
25
|
+
type ImportOptions,
|
|
26
|
+
type ImportResult,
|
|
27
|
+
type ImportColumnMap,
|
|
28
|
+
type ImportColumnTypes,
|
|
29
|
+
type ImportRowError,
|
|
30
|
+
type ImportValidator,
|
|
31
|
+
} from './import'
|
|
32
|
+
export { installPro, type ProGridApi, type ProAIApi, type ProPivotApi } from './install'
|
|
33
|
+
export {
|
|
34
|
+
createStagedEditing,
|
|
35
|
+
type StagedChange,
|
|
36
|
+
type StagedEditingApi,
|
|
37
|
+
type StagedEditingEvent,
|
|
38
|
+
} from './staged-editing'
|
|
39
|
+
export { dismissUnlicensedNudge } from './watermark'
|
|
40
|
+
export {
|
|
41
|
+
showUpgradePrompt,
|
|
42
|
+
dismissUpgradePrompt,
|
|
43
|
+
type ProFeatureLabel,
|
|
44
|
+
} from './upgrade-prompt'
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
createPivotModel,
|
|
48
|
+
pivotAggregators,
|
|
49
|
+
filterCollapsedPivotRows,
|
|
50
|
+
type PivotAggregator,
|
|
51
|
+
type PivotAggregatorId,
|
|
52
|
+
type PivotConfig,
|
|
53
|
+
type PivotResult,
|
|
54
|
+
type PivotRow,
|
|
55
|
+
type PivotRowKind,
|
|
56
|
+
type PivotValueConfig,
|
|
57
|
+
} from './pivot'
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
// Provider plumbing
|
|
61
|
+
setAIProvider,
|
|
62
|
+
getAIProvider,
|
|
63
|
+
hasAIProvider,
|
|
64
|
+
mockAIProvider,
|
|
65
|
+
type AIProvider,
|
|
66
|
+
type AIRequest,
|
|
67
|
+
type AITask,
|
|
68
|
+
// Filter
|
|
69
|
+
aiFilter,
|
|
70
|
+
type AIFilterOptions,
|
|
71
|
+
type AIFilterResult,
|
|
72
|
+
type AIFilterClause,
|
|
73
|
+
type AISortClause,
|
|
74
|
+
// Smart fill
|
|
75
|
+
aiSmartFill,
|
|
76
|
+
type AISmartFillOptions,
|
|
77
|
+
type AISmartFillResult,
|
|
78
|
+
type AISmartFillExample,
|
|
79
|
+
// Summarize
|
|
80
|
+
aiSummarize,
|
|
81
|
+
type AISummarizeOptions,
|
|
82
|
+
type AISummarizeTarget,
|
|
83
|
+
type AISummary,
|
|
84
|
+
// Classify
|
|
85
|
+
aiClassify,
|
|
86
|
+
type AIClassifyOptions,
|
|
87
|
+
type AIClassifyResult,
|
|
88
|
+
} from './ai'
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { RowData, TableFeatures, SvGridApi } from '@svgrid/grid'
|
|
2
|
+
import { exportGrid, type ExportOptions } from './export'
|
|
3
|
+
import { printGrid, type PrintOptions } from './print'
|
|
4
|
+
import { importData, type ImportOptions, type ImportResult } from './import'
|
|
5
|
+
import { isLicenseKeySet } from './license'
|
|
6
|
+
import { emitUnlicensedNudge } from './watermark'
|
|
7
|
+
import {
|
|
8
|
+
aiFilter, aiSmartFill, aiSummarize, aiClassify,
|
|
9
|
+
type AIFilterOptions, type AIFilterResult,
|
|
10
|
+
type AISmartFillOptions, type AISmartFillResult,
|
|
11
|
+
type AISummarizeOptions, type AISummary,
|
|
12
|
+
type AIClassifyOptions, type AIClassifyResult,
|
|
13
|
+
} from './ai'
|
|
14
|
+
import {
|
|
15
|
+
createPivotModel,
|
|
16
|
+
type PivotConfig,
|
|
17
|
+
type PivotResult,
|
|
18
|
+
} from './pivot'
|
|
19
|
+
|
|
20
|
+
export type ProAIApi<TData extends RowData> = {
|
|
21
|
+
/** Natural-language -> filter + sort plan (and optionally apply it). */
|
|
22
|
+
filter(query: string, opts?: AIFilterOptions): Promise<AIFilterResult>
|
|
23
|
+
/** Propose values for empty cells based on a few worked examples. */
|
|
24
|
+
smartFill<TValue = unknown>(opts: AISmartFillOptions): Promise<AISmartFillResult<TValue>>
|
|
25
|
+
/** One-paragraph + bullets summary of a row, selection, group, or the whole view. */
|
|
26
|
+
summarize(opts: AISummarizeOptions): Promise<AISummary>
|
|
27
|
+
/** Classify free-text cells into one of a known set of labels. */
|
|
28
|
+
classify(opts: AIClassifyOptions): Promise<AIClassifyResult>
|
|
29
|
+
// TData is referenced so the type stays bound to the row shape even
|
|
30
|
+
// though the helpers all read through `api.getData()`. Lets callers
|
|
31
|
+
// get correct inference downstream without explicit generics.
|
|
32
|
+
readonly _rowType?: TData
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ProPivotApi<
|
|
36
|
+
TFeatures extends TableFeatures,
|
|
37
|
+
TData extends RowData,
|
|
38
|
+
> = {
|
|
39
|
+
/**
|
|
40
|
+
* Build a pivot row/column model from the grid's current data. Returns
|
|
41
|
+
* `{ rows, columns }` you can feed to a separate `<SvGrid>` instance
|
|
42
|
+
* (the typical pattern - the source grid stays unmodified). Pure: no
|
|
43
|
+
* mutation, no DOM access.
|
|
44
|
+
*/
|
|
45
|
+
build(config: PivotConfig<TData>): PivotResult<TFeatures>
|
|
46
|
+
/**
|
|
47
|
+
* Build a pivot from an arbitrary array (not necessarily the grid's
|
|
48
|
+
* data). Useful for previewing a designer's config against a sample
|
|
49
|
+
* before committing.
|
|
50
|
+
*/
|
|
51
|
+
buildFrom<TIn extends RowData>(
|
|
52
|
+
data: ReadonlyArray<TIn>,
|
|
53
|
+
config: PivotConfig<TIn>,
|
|
54
|
+
): PivotResult<TFeatures>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ProGridApi<
|
|
58
|
+
TFeatures extends TableFeatures,
|
|
59
|
+
TData extends RowData,
|
|
60
|
+
> = SvGridApi<TFeatures, TData> & {
|
|
61
|
+
/** Export the current visible rows to the chosen format. */
|
|
62
|
+
exportData(opts: ExportOptions<TData>): Promise<void>
|
|
63
|
+
/** Open a printable view of the current visible rows in a new window. */
|
|
64
|
+
print(opts?: PrintOptions<TData>): Promise<void>
|
|
65
|
+
/** Read an Excel / CSV / TSV / JSON file (or inline text) into typed
|
|
66
|
+
* rows. Caller decides whether to commit into the grid or preview
|
|
67
|
+
* the parsed result first via the returned `ImportResult`. */
|
|
68
|
+
importData(opts: ImportOptions<TData>): Promise<ImportResult<TData>>
|
|
69
|
+
/** AI helpers (Pro). All calls route through a consumer-registered
|
|
70
|
+
* AIProvider via setAIProvider(); no model client is bundled. */
|
|
71
|
+
ai: ProAIApi<TData>
|
|
72
|
+
/** Pivot table builder (Pro). Pure - returns `{ rows, columns }` you
|
|
73
|
+
* hand to a separate `<SvGrid>` instance. */
|
|
74
|
+
pivot: ProPivotApi<TFeatures, TData>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Augment a SvGridApi instance with Pro methods (`exportData`, `print`,
|
|
79
|
+
* `ai.*`). Mutates and returns the same object so existing references
|
|
80
|
+
* keep working.
|
|
81
|
+
*
|
|
82
|
+
* If no license key has been set, the Pro methods still function but the
|
|
83
|
+
* grid shows a small "unlicensed" watermark linking to jqwidgets.com and
|
|
84
|
+
* the console emits a one-time nudge. Revoked or malformed keys throw on
|
|
85
|
+
* first call.
|
|
86
|
+
*/
|
|
87
|
+
export function installPro<
|
|
88
|
+
TFeatures extends TableFeatures,
|
|
89
|
+
TData extends RowData,
|
|
90
|
+
>(api: SvGridApi<TFeatures, TData>): ProGridApi<TFeatures, TData> {
|
|
91
|
+
const pro = api as ProGridApi<TFeatures, TData>
|
|
92
|
+
pro.exportData = (opts) => exportGrid(pro, opts)
|
|
93
|
+
pro.print = (opts) => printGrid(pro, opts)
|
|
94
|
+
pro.importData = (opts) => importData(pro, opts)
|
|
95
|
+
pro.ai = {
|
|
96
|
+
filter: (query, opts) => aiFilter(pro, query, opts),
|
|
97
|
+
smartFill: (opts) => aiSmartFill(pro, opts),
|
|
98
|
+
summarize: (opts) => aiSummarize(pro, opts),
|
|
99
|
+
classify: (opts) => aiClassify(pro, opts),
|
|
100
|
+
}
|
|
101
|
+
pro.pivot = {
|
|
102
|
+
build: (config) =>
|
|
103
|
+
createPivotModel<TFeatures, TData>(pro.getData(), config),
|
|
104
|
+
buildFrom: <TIn extends RowData>(data: ReadonlyArray<TIn>, config: PivotConfig<TIn>) =>
|
|
105
|
+
// PivotConfig is variance-strict in TIn; the runtime call only needs
|
|
106
|
+
// the row shape to match config.rows / config.cols field names, which
|
|
107
|
+
// the caller-side generic enforces.
|
|
108
|
+
createPivotModel<TFeatures, TIn>(data, config),
|
|
109
|
+
}
|
|
110
|
+
// Surface the soft-gate state at install time so the watermark appears
|
|
111
|
+
// alongside the grid, not just after the user clicks Export.
|
|
112
|
+
if (!isLicenseKeySet()) emitUnlicensedNudge()
|
|
113
|
+
return pro
|
|
114
|
+
}
|
package/src/license.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Polite license gate. Not crypto - anyone with devtools can extract the key
|
|
2
|
+
// from a deployed bundle. The point is to make commercial use require a
|
|
3
|
+
// transaction, not to defeat reverse engineering.
|
|
4
|
+
//
|
|
5
|
+
// Behavior matrix:
|
|
6
|
+
//
|
|
7
|
+
// currentKey state -> result
|
|
8
|
+
// ────────────────────────────────────────────────────────────────────
|
|
9
|
+
// null (no key set) -> soft-gate: watermark + console.log,
|
|
10
|
+
// feature still runs
|
|
11
|
+
// does not start with "SVPRO-" -> throws (programmer error)
|
|
12
|
+
// in REVOKED_KEYS -> throws (revoked / leaked / expired)
|
|
13
|
+
// starts with "SVPRO-DEV" or
|
|
14
|
+
// "SVPRO-EVAL" -> works; one-time console.info notice
|
|
15
|
+
// any other "SVPRO-..." -> works silently (paid production)
|
|
16
|
+
|
|
17
|
+
import { REVOKED_KEYS } from './revoked'
|
|
18
|
+
import { emitUnlicensedNudge } from './watermark'
|
|
19
|
+
import { showUpgradePrompt, type ProFeatureLabel } from './upgrade-prompt'
|
|
20
|
+
|
|
21
|
+
const VALID_PREFIX = 'SVPRO-'
|
|
22
|
+
let currentKey: string | null = null
|
|
23
|
+
let noticedDev = false
|
|
24
|
+
|
|
25
|
+
export function setLicenseKey(key: string): void {
|
|
26
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
27
|
+
throw new Error('@svgrid/enterprise: setLicenseKey() requires a non-empty string')
|
|
28
|
+
}
|
|
29
|
+
currentKey = key
|
|
30
|
+
noticedDev = false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearLicenseKey(): void {
|
|
34
|
+
currentKey = null
|
|
35
|
+
noticedDev = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getLicenseKey(): string | null {
|
|
39
|
+
return currentKey
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True if a key is set at all (regardless of validity). */
|
|
43
|
+
export function isLicenseKeySet(): boolean {
|
|
44
|
+
return currentKey != null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True if the current key passes every check: present, valid prefix, not
|
|
49
|
+
* revoked. Use this from callers that want to branch on license status
|
|
50
|
+
* (e.g. UI that hides Pro-only options when unlicensed).
|
|
51
|
+
*/
|
|
52
|
+
export function hasValidLicense(): boolean {
|
|
53
|
+
if (currentKey == null) return false
|
|
54
|
+
if (!currentKey.startsWith(VALID_PREFIX)) return false
|
|
55
|
+
if (REVOKED_KEYS.has(currentKey)) return false
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertProLicensed(feature?: ProFeatureLabel): void {
|
|
60
|
+
if (currentKey == null) {
|
|
61
|
+
// Soft-gate: the feature still runs, but the user gets a watermark +
|
|
62
|
+
// a one-time console.log nudge directing them to pricing, plus a
|
|
63
|
+
// contextual moment-of-intent upgrade card naming the feature they
|
|
64
|
+
// just reached for.
|
|
65
|
+
emitUnlicensedNudge()
|
|
66
|
+
showUpgradePrompt(feature)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (!currentKey.startsWith(VALID_PREFIX)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'@svgrid/enterprise: invalid license key format (expected "SVPRO-..." prefix).',
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
if (REVOKED_KEYS.has(currentKey)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'@svgrid/enterprise: this license key has been revoked. ' +
|
|
77
|
+
'Contact sales@jqwidgets.com for a replacement.',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
!noticedDev &&
|
|
82
|
+
(currentKey.startsWith('SVPRO-DEV') || currentKey.startsWith('SVPRO-EVAL'))
|
|
83
|
+
) {
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.info(
|
|
86
|
+
'@svgrid/enterprise: using a development / evaluation license. Not for production use.',
|
|
87
|
+
)
|
|
88
|
+
noticedDev = true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Ambient declarations for pdfmake submodules we lazy-import. pdfmake
|
|
2
|
+
// itself ships no .d.ts files and there is no @types/pdfmake on npm that
|
|
3
|
+
// covers `build/*`. Kept as a separate script-style .d.ts (no top-level
|
|
4
|
+
// import/export) so it functions as an ambient declaration, not a module
|
|
5
|
+
// augmentation. Pulled into export.ts via a triple-slash reference so
|
|
6
|
+
// consumers of @svgrid/enterprise pick the declarations up automatically.
|
|
7
|
+
|
|
8
|
+
declare module 'pdfmake/build/pdfmake' {
|
|
9
|
+
const pdfMake: {
|
|
10
|
+
vfs?: Record<string, string>
|
|
11
|
+
createPdf(definition: unknown): {
|
|
12
|
+
download(filename: string): void
|
|
13
|
+
getBlob(callback: (blob: Blob) => void): void
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export default pdfMake
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare module 'pdfmake/build/vfs_fonts' {
|
|
20
|
+
const vfsFonts: unknown
|
|
21
|
+
export default vfsFonts
|
|
22
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Pro pivot module: createPivotModel,
|
|
3
|
+
* filterCollapsedPivotRows, and pivotAggregators.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
createPivotModel,
|
|
8
|
+
filterCollapsedPivotRows,
|
|
9
|
+
pivotAggregators,
|
|
10
|
+
type PivotRow,
|
|
11
|
+
} from './pivot'
|
|
12
|
+
|
|
13
|
+
type Sale = {
|
|
14
|
+
region: 'AMER' | 'EMEA' | 'APAC'
|
|
15
|
+
salesPerson: string
|
|
16
|
+
quarter: '2026 Q1' | '2026 Q2'
|
|
17
|
+
amount: number
|
|
18
|
+
units: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const facts: Sale[] = [
|
|
22
|
+
{ region: 'AMER', salesPerson: 'Ada', quarter: '2026 Q1', amount: 100, units: 1 },
|
|
23
|
+
{ region: 'AMER', salesPerson: 'Ada', quarter: '2026 Q2', amount: 200, units: 2 },
|
|
24
|
+
{ region: 'AMER', salesPerson: 'Linus', quarter: '2026 Q1', amount: 300, units: 3 },
|
|
25
|
+
{ region: 'AMER', salesPerson: 'Linus', quarter: '2026 Q2', amount: 50, units: 1 },
|
|
26
|
+
{ region: 'EMEA', salesPerson: 'Tim', quarter: '2026 Q1', amount: 400, units: 4 },
|
|
27
|
+
{ region: 'EMEA', salesPerson: 'Tim', quarter: '2026 Q2', amount: 100, units: 5 },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
describe('createPivotModel - shape of the result', () => {
|
|
31
|
+
it('emits rows for each row-axis level plus a grand-total row', () => {
|
|
32
|
+
const r = createPivotModel(facts, {
|
|
33
|
+
rows: ['region', 'salesPerson'],
|
|
34
|
+
cols: ['quarter'],
|
|
35
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
36
|
+
})
|
|
37
|
+
// 2 regions × (1 group + N leaves) + grand total = AMER + Ada + Linus + EMEA + Tim + Grand
|
|
38
|
+
expect(r.rows.length).toBe(6)
|
|
39
|
+
expect(r.rows.map((row) => row.__pivotKind)).toEqual([
|
|
40
|
+
'group', 'leaf', 'leaf', 'group', 'leaf', 'grandTotal',
|
|
41
|
+
])
|
|
42
|
+
expect(r.rows.map((row) => row.__pivotLabel)).toEqual([
|
|
43
|
+
'AMER', 'Ada', 'Linus', 'EMEA', 'Tim', 'Grand total',
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('marks group rows as expandable and leaf rows as not', () => {
|
|
48
|
+
const r = createPivotModel(facts, {
|
|
49
|
+
rows: ['region', 'salesPerson'],
|
|
50
|
+
cols: ['quarter'],
|
|
51
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
52
|
+
})
|
|
53
|
+
const exp = r.rows.map((row) => row.__pivotExpandable)
|
|
54
|
+
expect(exp).toEqual([true, false, false, true, false, false])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('points each row.__pivotParentId at its row-axis parent', () => {
|
|
58
|
+
const r = createPivotModel(facts, {
|
|
59
|
+
rows: ['region', 'salesPerson'],
|
|
60
|
+
cols: ['quarter'],
|
|
61
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
62
|
+
})
|
|
63
|
+
const byId = new Map(r.rows.map((row) => [row.__pivotId, row]))
|
|
64
|
+
const ada = r.rows.find((row) => row.__pivotLabel === 'Ada')!
|
|
65
|
+
const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
|
|
66
|
+
expect(ada.__pivotParentId).toBe(amer.__pivotId)
|
|
67
|
+
expect(amer.__pivotParentId).toBeNull()
|
|
68
|
+
expect(byId.get(ada.__pivotParentId!)).toBe(amer)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('createPivotModel - aggregated values', () => {
|
|
73
|
+
it('computes sum per (row-leaf × col-leaf) cell', () => {
|
|
74
|
+
const r = createPivotModel(facts, {
|
|
75
|
+
rows: ['region', 'salesPerson'],
|
|
76
|
+
cols: ['quarter'],
|
|
77
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
78
|
+
})
|
|
79
|
+
const ada = r.rows.find((row) => row.__pivotLabel === 'Ada')!
|
|
80
|
+
const q1Col = r.columns
|
|
81
|
+
.flatMap((c: any) => (c.columns ? c.columns : [c]))
|
|
82
|
+
.find((c: any) => c.id?.startsWith('pv__') && c.header === 'amount (sum)')
|
|
83
|
+
expect(q1Col).toBeDefined()
|
|
84
|
+
// Ada's Q1 sum is 100; Q2 sum is 200. Both leaves exist as separate ids.
|
|
85
|
+
const q1Id = Object.keys(ada).find((k) => k.startsWith('pv__2026') && k.includes('Q1'))!
|
|
86
|
+
const q2Id = Object.keys(ada).find((k) => k.startsWith('pv__2026') && k.includes('Q2'))!
|
|
87
|
+
expect(ada[q1Id]).toBe(100)
|
|
88
|
+
expect(ada[q2Id]).toBe(200)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('emits a group row with the subtotal across all children', () => {
|
|
92
|
+
const r = createPivotModel(facts, {
|
|
93
|
+
rows: ['region', 'salesPerson'],
|
|
94
|
+
cols: ['quarter'],
|
|
95
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
96
|
+
})
|
|
97
|
+
const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
|
|
98
|
+
const q1Id = Object.keys(amer).find((k) => k.startsWith('pv__2026') && k.includes('Q1'))!
|
|
99
|
+
// AMER Q1 = Ada (100) + Linus (300) = 400
|
|
100
|
+
expect(amer[q1Id]).toBe(400)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('emits a grand-total row that sums every fact', () => {
|
|
104
|
+
const r = createPivotModel(facts, {
|
|
105
|
+
rows: ['region', 'salesPerson'],
|
|
106
|
+
cols: ['quarter'],
|
|
107
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
108
|
+
})
|
|
109
|
+
const grand = r.rows.find((row) => row.__pivotKind === 'grandTotal')!
|
|
110
|
+
const q1Id = Object.keys(grand).find((k) => k.startsWith('pv__2026') && k.includes('Q1'))!
|
|
111
|
+
// Q1 total: 100 + 300 + 400 = 800
|
|
112
|
+
expect(grand[q1Id]).toBe(800)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('supports multiple measures (one column per measure × col-axis path)', () => {
|
|
116
|
+
const r = createPivotModel(facts, {
|
|
117
|
+
rows: ['region'],
|
|
118
|
+
cols: ['quarter'],
|
|
119
|
+
values: [
|
|
120
|
+
{ field: 'amount', agg: 'sum' },
|
|
121
|
+
{ field: 'units', agg: 'sum' },
|
|
122
|
+
],
|
|
123
|
+
})
|
|
124
|
+
const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
|
|
125
|
+
const amountKeys = Object.keys(amer).filter((k) => k.startsWith('pv__') && k.endsWith('__m0'))
|
|
126
|
+
const unitsKeys = Object.keys(amer).filter((k) => k.startsWith('pv__') && k.endsWith('__m1'))
|
|
127
|
+
// 2 col-axis values (Q1, Q2) + 1 grand-total column = 3 keys per measure
|
|
128
|
+
expect(amountKeys.length).toBe(3)
|
|
129
|
+
expect(unitsKeys.length).toBe(3)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('honours a custom aggregator function', () => {
|
|
133
|
+
// Weighted average via custom function
|
|
134
|
+
const r = createPivotModel(facts, {
|
|
135
|
+
rows: ['region'],
|
|
136
|
+
cols: [],
|
|
137
|
+
values: [
|
|
138
|
+
{
|
|
139
|
+
field: 'amount', label: 'weighted', agg: (vs) => {
|
|
140
|
+
const ns = vs.map(Number).filter((n) => !Number.isNaN(n))
|
|
141
|
+
if (ns.length === 0) return 0
|
|
142
|
+
return ns.reduce((a, b) => a + b, 0) / ns.length
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
})
|
|
147
|
+
const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
|
|
148
|
+
const k = Object.keys(amer).find((k) => k.startsWith('pv__'))!
|
|
149
|
+
// AMER amounts: 100, 200, 300, 50 -> avg = 162.5
|
|
150
|
+
expect(amer[k]).toBeCloseTo(162.5, 5)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('createPivotModel - configuration toggles', () => {
|
|
155
|
+
it('omits the grand-total row when grandTotalRow=false', () => {
|
|
156
|
+
const r = createPivotModel(facts, {
|
|
157
|
+
rows: ['region'],
|
|
158
|
+
cols: [],
|
|
159
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
160
|
+
grandTotalRow: false,
|
|
161
|
+
})
|
|
162
|
+
expect(r.rows.find((row) => row.__pivotKind === 'grandTotal')).toBeUndefined()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('omits row subtotals when rowSubtotals=false', () => {
|
|
166
|
+
const r = createPivotModel(facts, {
|
|
167
|
+
rows: ['region', 'salesPerson'],
|
|
168
|
+
cols: ['quarter'],
|
|
169
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
170
|
+
rowSubtotals: false,
|
|
171
|
+
})
|
|
172
|
+
// Only leaves + grand total - no `group` rows.
|
|
173
|
+
expect(r.rows.filter((row) => row.__pivotKind === 'group')).toEqual([])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('honours a custom rowSort comparator', () => {
|
|
177
|
+
const r = createPivotModel(facts, {
|
|
178
|
+
rows: ['region'],
|
|
179
|
+
cols: [],
|
|
180
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
181
|
+
rowSort: (a, b) => String(b).localeCompare(String(a)), // reverse-alpha
|
|
182
|
+
})
|
|
183
|
+
const regionLabels = r.rows
|
|
184
|
+
.filter((row) => row.__pivotKind === 'leaf')
|
|
185
|
+
.map((row) => row.__pivotLabel)
|
|
186
|
+
expect(regionLabels).toEqual(['EMEA', 'AMER'])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('pivotAggregators - built-ins', () => {
|
|
191
|
+
it('exposes sum / avg / min / max / count / countDistinct / first / last', () => {
|
|
192
|
+
expect(Object.keys(pivotAggregators).sort()).toEqual([
|
|
193
|
+
'avg', 'count', 'countDistinct', 'first', 'last', 'max', 'min', 'sum',
|
|
194
|
+
])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('sum + avg coerce non-numeric values to NaN and drop them', () => {
|
|
198
|
+
expect(pivotAggregators.sum([10, 20, 'oops', null, 30])).toBe(60)
|
|
199
|
+
expect(pivotAggregators.avg([10, 20, 30])).toBe(20)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('avg returns null for empty inputs (vs. 0, to distinguish from a real 0)', () => {
|
|
203
|
+
expect(pivotAggregators.avg([])).toBeNull()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('countDistinct deduplicates by strict equality', () => {
|
|
207
|
+
expect(pivotAggregators.countDistinct(['a', 'a', 'b', 'b', 'c'])).toBe(3)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('first/last pick the corresponding entries', () => {
|
|
211
|
+
expect(pivotAggregators.first(['x', 'y', 'z'])).toBe('x')
|
|
212
|
+
expect(pivotAggregators.last(['x', 'y', 'z'])).toBe('z')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('filterCollapsedPivotRows', () => {
|
|
217
|
+
function pivot() {
|
|
218
|
+
return createPivotModel(facts, {
|
|
219
|
+
rows: ['region', 'salesPerson'],
|
|
220
|
+
cols: ['quarter'],
|
|
221
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
it('returns every row when expanded is `true`', () => {
|
|
226
|
+
const r = pivot()
|
|
227
|
+
const out = filterCollapsedPivotRows(r.rows, true)
|
|
228
|
+
expect(out.length).toBe(r.rows.length)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('hides leaves under collapsed groups (empty expansion set)', () => {
|
|
232
|
+
const r = pivot()
|
|
233
|
+
const out = filterCollapsedPivotRows(r.rows, [])
|
|
234
|
+
// Only top-level groups + grand-total stay; no leaves
|
|
235
|
+
expect(out.map((row) => row.__pivotLabel)).toEqual(['AMER', 'EMEA', 'Grand total'])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('shows leaves of the one expanded group', () => {
|
|
239
|
+
const r = pivot()
|
|
240
|
+
const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
|
|
241
|
+
const out = filterCollapsedPivotRows(r.rows, [amer.__pivotId])
|
|
242
|
+
expect(out.map((row) => row.__pivotLabel)).toEqual([
|
|
243
|
+
'AMER', 'Ada', 'Linus', 'EMEA', 'Grand total',
|
|
244
|
+
])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('accepts a Set as well as an array', () => {
|
|
248
|
+
const r = pivot()
|
|
249
|
+
const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
|
|
250
|
+
const out = filterCollapsedPivotRows(r.rows, new Set([amer.__pivotId]))
|
|
251
|
+
expect(out.find((row) => row.__pivotLabel === 'Ada')).toBeDefined()
|
|
252
|
+
expect(out.find((row) => row.__pivotLabel === 'Tim')).toBeUndefined()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('does not mutate the input array', () => {
|
|
256
|
+
const r = pivot()
|
|
257
|
+
const original = r.rows.slice()
|
|
258
|
+
filterCollapsedPivotRows(r.rows, [])
|
|
259
|
+
expect(r.rows).toEqual(original)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('always keeps rows with a null parentId visible (grand total, top groups)', () => {
|
|
263
|
+
const r = pivot()
|
|
264
|
+
const out = filterCollapsedPivotRows(r.rows, [])
|
|
265
|
+
const visibleKinds = out.map((row) => row.__pivotKind)
|
|
266
|
+
expect(visibleKinds).toContain('grandTotal')
|
|
267
|
+
expect(visibleKinds.filter((k) => k === 'group').length).toBe(2)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('createPivotModel - tracking via pivotId', () => {
|
|
272
|
+
it('produces stable pivotIds for the same source data + config', () => {
|
|
273
|
+
const a = createPivotModel(facts, {
|
|
274
|
+
rows: ['region'],
|
|
275
|
+
cols: [],
|
|
276
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
277
|
+
})
|
|
278
|
+
const b = createPivotModel(facts, {
|
|
279
|
+
rows: ['region'],
|
|
280
|
+
cols: [],
|
|
281
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
282
|
+
})
|
|
283
|
+
expect(a.rows.map((r) => r.__pivotId)).toEqual(b.rows.map((r) => r.__pivotId))
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('every row.__pivotId is unique within one pivot', () => {
|
|
287
|
+
const r = createPivotModel(facts, {
|
|
288
|
+
rows: ['region', 'salesPerson'],
|
|
289
|
+
cols: ['quarter'],
|
|
290
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
291
|
+
})
|
|
292
|
+
const ids = r.rows.map((row) => row.__pivotId)
|
|
293
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('every row.__pivotParentId resolves to another row (or null)', () => {
|
|
297
|
+
const r = createPivotModel(facts, {
|
|
298
|
+
rows: ['region', 'salesPerson'],
|
|
299
|
+
cols: ['quarter'],
|
|
300
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
301
|
+
})
|
|
302
|
+
const ids = new Set(r.rows.map((row) => row.__pivotId))
|
|
303
|
+
for (const row of r.rows) {
|
|
304
|
+
if (row.__pivotParentId === null) continue
|
|
305
|
+
expect(ids.has(row.__pivotParentId)).toBe(true)
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
})
|