@svgrid/enterprise 1.0.1 → 1.2.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/LICENSE +97 -35
- package/README.md +50 -18
- package/dist/cdn/pdfmake-C6pyKjX0.js +58641 -0
- package/dist/cdn/smart.export-CfW2zpyq.js +2029 -0
- package/dist/cdn/svgrid-enterprise.svelte-external.js +1895 -0
- package/dist/cdn/vfs_fonts-BktEULEs.js +13 -0
- package/package.json +83 -71
- package/src/SvPivotDesigner.svelte +1045 -0
- package/src/ai.test.ts +1 -1
- package/src/ai.ts +6 -6
- package/src/export.ts +39 -2
- package/src/import.test.ts +1 -1
- package/src/import.ts +2 -2
- package/src/index.ts +17 -3
- package/src/install.ts +8 -8
- package/src/license.ts +9 -9
- package/src/pivot-designer.ts +101 -0
- package/src/pivot.test.ts +31 -3
- package/src/pivot.ts +42 -13
- package/src/print.ts +2 -2
- package/src/revoked.ts +3 -3
- package/src/upgrade-prompt.test.ts +1 -1
- package/src/upgrade-prompt.ts +5 -5
- package/src/watermark.test.ts +1 -1
- package/src/watermark.ts +3 -3
package/src/ai.test.ts
CHANGED
|
@@ -70,7 +70,7 @@ beforeEach(() => {
|
|
|
70
70
|
// Default to a licensed state so the soft-gate doesn't dump noise on the
|
|
71
71
|
// console during expected-failure tests. Individual cases clear it when
|
|
72
72
|
// they want to assert soft-gate behaviour.
|
|
73
|
-
setLicenseKey('
|
|
73
|
+
setLicenseKey('SVENTERPRISE-DEV-TEST')
|
|
74
74
|
})
|
|
75
75
|
afterEach(() => {
|
|
76
76
|
setAIProvider(null)
|
package/src/ai.ts
CHANGED
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
* `AIProvider` that knows how to call OpenAI / Anthropic / a local
|
|
21
21
|
* model / a server-side proxy. We never bundle a model client.
|
|
22
22
|
*
|
|
23
|
-
* License gate: every AI call routes through `
|
|
23
|
+
* License gate: every AI call routes through `assertEnterpriseLicensed()`,
|
|
24
24
|
* which soft-gates (still works, adds a watermark, console nudge) when
|
|
25
25
|
* no key is set so demos and evaluation stay frictionless.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { RowData, SvGridApi, TableFeatures } from '@svgrid/grid'
|
|
29
|
-
import {
|
|
29
|
+
import { assertEnterpriseLicensed } from './license'
|
|
30
30
|
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
// Provider contract
|
|
@@ -230,7 +230,7 @@ export async function aiFilter<
|
|
|
230
230
|
query: string,
|
|
231
231
|
opts: AIFilterOptions = {},
|
|
232
232
|
): Promise<AIFilterResult> {
|
|
233
|
-
|
|
233
|
+
assertEnterpriseLicensed('AI assistant')
|
|
234
234
|
const schema = buildColumnSchema(api)
|
|
235
235
|
const prompt =
|
|
236
236
|
`You are a data-grid filter planner. Translate the user's natural-language ` +
|
|
@@ -317,7 +317,7 @@ export async function aiSmartFill<
|
|
|
317
317
|
api: SvGridApi<TFeatures, TData>,
|
|
318
318
|
opts: AISmartFillOptions,
|
|
319
319
|
): Promise<AISmartFillResult<TValue>> {
|
|
320
|
-
|
|
320
|
+
assertEnterpriseLicensed('AI assistant')
|
|
321
321
|
if (opts.examples.length === 0) {
|
|
322
322
|
throw new Error('@svgrid/enterprise/ai: aiSmartFill requires at least one example.')
|
|
323
323
|
}
|
|
@@ -408,7 +408,7 @@ export async function aiSummarize<
|
|
|
408
408
|
api: SvGridApi<TFeatures, TData>,
|
|
409
409
|
opts: AISummarizeOptions,
|
|
410
410
|
): Promise<AISummary> {
|
|
411
|
-
|
|
411
|
+
assertEnterpriseLicensed('AI assistant')
|
|
412
412
|
const all = api.getData()
|
|
413
413
|
const rows: TData[] = (() => {
|
|
414
414
|
const t = opts.target
|
|
@@ -497,7 +497,7 @@ export async function aiClassify<
|
|
|
497
497
|
api: SvGridApi<TFeatures, TData>,
|
|
498
498
|
opts: AIClassifyOptions,
|
|
499
499
|
): Promise<AIClassifyResult> {
|
|
500
|
-
|
|
500
|
+
assertEnterpriseLicensed('AI assistant')
|
|
501
501
|
const data = api.getData()
|
|
502
502
|
const targets = opts.targetRowIndices ?? data.map((_, i) => i)
|
|
503
503
|
const rubric = opts.classDescriptions
|
package/src/export.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference path="./pdfmake-shims.d.ts" />
|
|
2
2
|
import type { RowData, TableFeatures } from '@svgrid/grid'
|
|
3
3
|
import type { SvGridApi } from '@svgrid/grid'
|
|
4
|
-
import {
|
|
4
|
+
import { assertEnterpriseLicensed } from './license'
|
|
5
5
|
import { installSmartShim, type SmartDataExporterInstance } from './smart-shim'
|
|
6
6
|
|
|
7
7
|
export type ExportFormat = 'xlsx' | 'pdf' | 'csv' | 'tsv' | 'html'
|
|
@@ -126,6 +126,30 @@ export type ExportOptions<TData> = {
|
|
|
126
126
|
* Mutually exclusive with `groupBy`. xlsx only.
|
|
127
127
|
*/
|
|
128
128
|
hierarchical?: boolean
|
|
129
|
+
/**
|
|
130
|
+
* Merged cells to write into the sheet (xlsx / pdf). Each entry spans
|
|
131
|
+
* `colSpan` columns and `rowSpan` rows starting at the given zero-based
|
|
132
|
+
* **body** row / column index (the header row is not counted). Mirrors the
|
|
133
|
+
* grid's own `MergeSpec` shape closely, so grid merges map straight through -
|
|
134
|
+
* convert a `MergeSpec` (`{ rowIndex, columnId, rowspan, colspan }`) by
|
|
135
|
+
* resolving `columnId` to its column index. Mutually exclusive with
|
|
136
|
+
* `groupBy` / `hierarchical`.
|
|
137
|
+
*/
|
|
138
|
+
merges?: ReadonlyArray<ExportMerge>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A merged cell region for {@link ExportOptions.merges}. `row` / `col` are
|
|
143
|
+
* zero-based indices into the exported body (header excluded). A cell that
|
|
144
|
+
* spans two columns to the right is `{ row, col, colSpan: 2 }`.
|
|
145
|
+
*/
|
|
146
|
+
export type ExportMerge = {
|
|
147
|
+
row: number
|
|
148
|
+
col: number
|
|
149
|
+
/** Columns to span (default 1). */
|
|
150
|
+
colSpan?: number
|
|
151
|
+
/** Rows to span (default 1). */
|
|
152
|
+
rowSpan?: number
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
let exporterCtorPromise: Promise<
|
|
@@ -398,6 +422,19 @@ function buildExporterOptions<TData>(
|
|
|
398
422
|
// hierarchical through here and groupBy through the constructor call
|
|
399
423
|
// site in exportGrid.
|
|
400
424
|
if (opts.hierarchical) out.hierarchical = true
|
|
425
|
+
// Merged cells: translate the friendly {row, col, rowSpan, colSpan} shape to
|
|
426
|
+
// Smart's native {cell: [row, col], rowspan, colspan}. Skip 1x1 "merges"
|
|
427
|
+
// (nothing to span). Ignored when grouping/hierarchy owns the row layout.
|
|
428
|
+
if (opts.merges?.length && !opts.groupBy?.length && !opts.hierarchical) {
|
|
429
|
+
const mergedCells = opts.merges
|
|
430
|
+
.map((m) => ({
|
|
431
|
+
cell: [m.row, m.col],
|
|
432
|
+
rowspan: Math.max(1, Math.floor(m.rowSpan ?? 1)),
|
|
433
|
+
colspan: Math.max(1, Math.floor(m.colSpan ?? 1)),
|
|
434
|
+
}))
|
|
435
|
+
.filter((m) => m.rowspan > 1 || m.colspan > 1)
|
|
436
|
+
if (mergedCells.length) out.mergedCells = mergedCells
|
|
437
|
+
}
|
|
401
438
|
return out
|
|
402
439
|
}
|
|
403
440
|
|
|
@@ -432,7 +469,7 @@ export async function exportGrid<
|
|
|
432
469
|
TFeatures extends TableFeatures,
|
|
433
470
|
TData extends RowData,
|
|
434
471
|
>(api: SvGridApi<TFeatures, TData>, opts: ExportOptions<TData>): Promise<void> {
|
|
435
|
-
|
|
472
|
+
assertEnterpriseLicensed('Export')
|
|
436
473
|
await ensureGlobals(opts.format)
|
|
437
474
|
const Ctor = await getDataExporter()
|
|
438
475
|
|
package/src/import.test.ts
CHANGED
package/src/import.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { RowData, SvGridApi, TableFeatures } from '@svgrid/grid'
|
|
28
|
-
import {
|
|
28
|
+
import { assertEnterpriseLicensed } from './license'
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// Public types
|
|
@@ -557,7 +557,7 @@ export async function importData<
|
|
|
557
557
|
api: SvGridApi<TFeatures, TData>,
|
|
558
558
|
opts: ImportOptions<TData>,
|
|
559
559
|
): Promise<ImportResult<TData>> {
|
|
560
|
-
|
|
560
|
+
assertEnterpriseLicensed('Import')
|
|
561
561
|
const format = sniffFormat(opts.file, opts.format ?? 'auto')
|
|
562
562
|
|
|
563
563
|
let matrix: string[][]
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ export {
|
|
|
4
4
|
getLicenseKey,
|
|
5
5
|
isLicenseKeySet,
|
|
6
6
|
hasValidLicense,
|
|
7
|
-
|
|
7
|
+
assertEnterpriseLicensed,
|
|
8
8
|
} from './license'
|
|
9
9
|
|
|
10
10
|
export {
|
|
@@ -29,7 +29,7 @@ export {
|
|
|
29
29
|
type ImportRowError,
|
|
30
30
|
type ImportValidator,
|
|
31
31
|
} from './import'
|
|
32
|
-
export {
|
|
32
|
+
export { installEnterprise, type EnterpriseGridApi, type EnterpriseAIApi, type EnterprisePivotApi } from './install'
|
|
33
33
|
export {
|
|
34
34
|
createStagedEditing,
|
|
35
35
|
type StagedChange,
|
|
@@ -40,7 +40,7 @@ export { dismissUnlicensedNudge } from './watermark'
|
|
|
40
40
|
export {
|
|
41
41
|
showUpgradePrompt,
|
|
42
42
|
dismissUpgradePrompt,
|
|
43
|
-
type
|
|
43
|
+
type EnterpriseFeatureLabel,
|
|
44
44
|
} from './upgrade-prompt'
|
|
45
45
|
|
|
46
46
|
export {
|
|
@@ -56,6 +56,20 @@ export {
|
|
|
56
56
|
type PivotValueConfig,
|
|
57
57
|
} from './pivot'
|
|
58
58
|
|
|
59
|
+
export { default as SvPivotDesigner } from './SvPivotDesigner.svelte'
|
|
60
|
+
export {
|
|
61
|
+
ALL_AGGREGATORS,
|
|
62
|
+
AGG_LABEL,
|
|
63
|
+
EMPTY_LAYOUT,
|
|
64
|
+
defaultLayoutFor,
|
|
65
|
+
type PivotField,
|
|
66
|
+
type PivotValueChip,
|
|
67
|
+
type PivotFilterChip,
|
|
68
|
+
type PivotLayout,
|
|
69
|
+
type PivotPreset,
|
|
70
|
+
type Well,
|
|
71
|
+
} from './pivot-designer'
|
|
72
|
+
|
|
59
73
|
export {
|
|
60
74
|
// Provider plumbing
|
|
61
75
|
setAIProvider,
|
package/src/install.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
type PivotResult,
|
|
18
18
|
} from './pivot'
|
|
19
19
|
|
|
20
|
-
export type
|
|
20
|
+
export type EnterpriseAIApi<TData extends RowData> = {
|
|
21
21
|
/** Natural-language -> filter + sort plan (and optionally apply it). */
|
|
22
22
|
filter(query: string, opts?: AIFilterOptions): Promise<AIFilterResult>
|
|
23
23
|
/** Propose values for empty cells based on a few worked examples. */
|
|
@@ -32,7 +32,7 @@ export type ProAIApi<TData extends RowData> = {
|
|
|
32
32
|
readonly _rowType?: TData
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export type
|
|
35
|
+
export type EnterprisePivotApi<
|
|
36
36
|
TFeatures extends TableFeatures,
|
|
37
37
|
TData extends RowData,
|
|
38
38
|
> = {
|
|
@@ -54,7 +54,7 @@ export type ProPivotApi<
|
|
|
54
54
|
): PivotResult<TFeatures>
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export type
|
|
57
|
+
export type EnterpriseGridApi<
|
|
58
58
|
TFeatures extends TableFeatures,
|
|
59
59
|
TData extends RowData,
|
|
60
60
|
> = SvGridApi<TFeatures, TData> & {
|
|
@@ -68,10 +68,10 @@ export type ProGridApi<
|
|
|
68
68
|
importData(opts: ImportOptions<TData>): Promise<ImportResult<TData>>
|
|
69
69
|
/** AI helpers (Pro). All calls route through a consumer-registered
|
|
70
70
|
* AIProvider via setAIProvider(); no model client is bundled. */
|
|
71
|
-
ai:
|
|
71
|
+
ai: EnterpriseAIApi<TData>
|
|
72
72
|
/** Pivot table builder (Pro). Pure - returns `{ rows, columns }` you
|
|
73
73
|
* hand to a separate `<SvGrid>` instance. */
|
|
74
|
-
pivot:
|
|
74
|
+
pivot: EnterprisePivotApi<TFeatures, TData>
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
@@ -84,11 +84,11 @@ export type ProGridApi<
|
|
|
84
84
|
* the console emits a one-time nudge. Revoked or malformed keys throw on
|
|
85
85
|
* first call.
|
|
86
86
|
*/
|
|
87
|
-
export function
|
|
87
|
+
export function installEnterprise<
|
|
88
88
|
TFeatures extends TableFeatures,
|
|
89
89
|
TData extends RowData,
|
|
90
|
-
>(api: SvGridApi<TFeatures, TData>):
|
|
91
|
-
const pro = api as
|
|
90
|
+
>(api: SvGridApi<TFeatures, TData>): EnterpriseGridApi<TFeatures, TData> {
|
|
91
|
+
const pro = api as EnterpriseGridApi<TFeatures, TData>
|
|
92
92
|
pro.exportData = (opts) => exportGrid(pro, opts)
|
|
93
93
|
pro.print = (opts) => printGrid(pro, opts)
|
|
94
94
|
pro.importData = (opts) => importData(pro, opts)
|
package/src/license.ts
CHANGED
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
// ────────────────────────────────────────────────────────────────────
|
|
9
9
|
// null (no key set) -> soft-gate: watermark + console.log,
|
|
10
10
|
// feature still runs
|
|
11
|
-
// does not start with "
|
|
11
|
+
// does not start with "SVENTERPRISE-" -> throws (programmer error)
|
|
12
12
|
// in REVOKED_KEYS -> throws (revoked / leaked / expired)
|
|
13
|
-
// starts with "
|
|
14
|
-
// "
|
|
15
|
-
// any other "
|
|
13
|
+
// starts with "SVENTERPRISE-DEV" or
|
|
14
|
+
// "SVENTERPRISE-EVAL" -> works; one-time console.info notice
|
|
15
|
+
// any other "SVENTERPRISE-..." -> works silently (paid production)
|
|
16
16
|
|
|
17
17
|
import { REVOKED_KEYS } from './revoked'
|
|
18
18
|
import { emitUnlicensedNudge } from './watermark'
|
|
19
|
-
import { showUpgradePrompt, type
|
|
19
|
+
import { showUpgradePrompt, type EnterpriseFeatureLabel } from './upgrade-prompt'
|
|
20
20
|
|
|
21
|
-
const VALID_PREFIX = '
|
|
21
|
+
const VALID_PREFIX = 'SVENTERPRISE-'
|
|
22
22
|
let currentKey: string | null = null
|
|
23
23
|
let noticedDev = false
|
|
24
24
|
|
|
@@ -56,7 +56,7 @@ export function hasValidLicense(): boolean {
|
|
|
56
56
|
return true
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function
|
|
59
|
+
export function assertEnterpriseLicensed(feature?: EnterpriseFeatureLabel): void {
|
|
60
60
|
if (currentKey == null) {
|
|
61
61
|
// Soft-gate: the feature still runs, but the user gets a watermark +
|
|
62
62
|
// a one-time console.log nudge directing them to pricing, plus a
|
|
@@ -68,7 +68,7 @@ export function assertProLicensed(feature?: ProFeatureLabel): void {
|
|
|
68
68
|
}
|
|
69
69
|
if (!currentKey.startsWith(VALID_PREFIX)) {
|
|
70
70
|
throw new Error(
|
|
71
|
-
'@svgrid/enterprise: invalid license key format (expected "
|
|
71
|
+
'@svgrid/enterprise: invalid license key format (expected "SVENTERPRISE-..." prefix).',
|
|
72
72
|
)
|
|
73
73
|
}
|
|
74
74
|
if (REVOKED_KEYS.has(currentKey)) {
|
|
@@ -79,7 +79,7 @@ export function assertProLicensed(feature?: ProFeatureLabel): void {
|
|
|
79
79
|
}
|
|
80
80
|
if (
|
|
81
81
|
!noticedDev &&
|
|
82
|
-
(currentKey.startsWith('
|
|
82
|
+
(currentKey.startsWith('SVENTERPRISE-DEV') || currentKey.startsWith('SVENTERPRISE-EVAL'))
|
|
83
83
|
) {
|
|
84
84
|
// eslint-disable-next-line no-console
|
|
85
85
|
console.info(
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types + helpers for the SvPivotDesigner component.
|
|
3
|
+
*
|
|
4
|
+
* Why a separate module: the component file (.svelte) imports these,
|
|
5
|
+
* and so do consumers wiring up custom presets, formatters, or wells.
|
|
6
|
+
* Keeping the pure-TS surface here also lets us unit-test the layout
|
|
7
|
+
* normalisation + serialisation without instantiating the component.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CellFormatConfig } from '@svgrid/grid'
|
|
11
|
+
import type { PivotAggregatorId } from './pivot'
|
|
12
|
+
|
|
13
|
+
/** One field the designer offers in its left-rail picker.
|
|
14
|
+
* Dimensions land in Rows by default; measures land in Values. */
|
|
15
|
+
export type PivotField<T extends Record<string, unknown> = Record<string, unknown>> = {
|
|
16
|
+
field: keyof T & string
|
|
17
|
+
/** Display name shown in the picker + on chips. */
|
|
18
|
+
label: string
|
|
19
|
+
/** Default well: 'dimension' -> Rows, 'measure' -> Values. */
|
|
20
|
+
kind: 'dimension' | 'measure'
|
|
21
|
+
/** Optional group name for the field picker (e.g. 'Demographics'). */
|
|
22
|
+
group?: string
|
|
23
|
+
/** Optional formatter applied to value-well chips by default. */
|
|
24
|
+
format?: CellFormatConfig
|
|
25
|
+
/** Optional default aggregator when added to Values (default 'sum'). */
|
|
26
|
+
defaultAgg?: PivotAggregatorId
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A single chip in the Values well. */
|
|
30
|
+
export type PivotValueChip = {
|
|
31
|
+
field: string
|
|
32
|
+
agg: PivotAggregatorId
|
|
33
|
+
label?: string
|
|
34
|
+
format?: CellFormatConfig
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A single chip in the Filters well. `allowed === null` => all values pass. */
|
|
38
|
+
export type PivotFilterChip = {
|
|
39
|
+
field: string
|
|
40
|
+
/** Distinct values that pass the filter. `null` means no restriction. */
|
|
41
|
+
allowed: string[] | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The full pivot layout. The designer is a controlled component that
|
|
45
|
+
* reads + writes this single shape via `bind:layout`. */
|
|
46
|
+
export type PivotLayout = {
|
|
47
|
+
rows: string[]
|
|
48
|
+
cols: string[]
|
|
49
|
+
values: PivotValueChip[]
|
|
50
|
+
filters: PivotFilterChip[]
|
|
51
|
+
/** Hide subtotal rows between groups. Default false. */
|
|
52
|
+
hideSubtotals?: boolean
|
|
53
|
+
/** Hide grand-total row + column. Default false. */
|
|
54
|
+
hideGrandTotals?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A saved layout the user can pick from the toolbar's preset menu. */
|
|
58
|
+
export type PivotPreset = {
|
|
59
|
+
name: string
|
|
60
|
+
layout: PivotLayout
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A built-in aggregator option. Consumers can pass a subset to
|
|
64
|
+
* `aggregators` to restrict the chip menu (e.g. 'count' only). */
|
|
65
|
+
export const ALL_AGGREGATORS: PivotAggregatorId[] = [
|
|
66
|
+
'sum', 'avg', 'min', 'max', 'count', 'countDistinct', 'first', 'last',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/** Empty layout used by Reset + the initial state. */
|
|
70
|
+
export const EMPTY_LAYOUT: PivotLayout = {
|
|
71
|
+
rows: [], cols: [], values: [], filters: [],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build an initial layout from a field list - useful when the
|
|
75
|
+
* consumer just wants "throw the first dimension in Rows + first
|
|
76
|
+
* measure in Values" defaults. */
|
|
77
|
+
export function defaultLayoutFor<T extends Record<string, unknown>>(
|
|
78
|
+
fields: ReadonlyArray<PivotField<T>>,
|
|
79
|
+
): PivotLayout {
|
|
80
|
+
const dim = fields.find((f) => f.kind === 'dimension')
|
|
81
|
+
const meas = fields.find((f) => f.kind === 'measure')
|
|
82
|
+
return {
|
|
83
|
+
rows: dim ? [dim.field] : [],
|
|
84
|
+
cols: [],
|
|
85
|
+
values: meas
|
|
86
|
+
? [{ field: meas.field, agg: meas.defaultAgg ?? 'sum', label: meas.label, format: meas.format }]
|
|
87
|
+
: [],
|
|
88
|
+
filters: [],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Move / add / remove a chip across the four wells. The well names
|
|
93
|
+
* match the layout shape. */
|
|
94
|
+
export type Well = 'rows' | 'cols' | 'values' | 'filters'
|
|
95
|
+
|
|
96
|
+
/** Pretty label for an aggregator id. */
|
|
97
|
+
export const AGG_LABEL: Record<PivotAggregatorId, string> = {
|
|
98
|
+
sum: 'Sum', avg: 'Average', min: 'Min', max: 'Max',
|
|
99
|
+
count: 'Count', countDistinct: 'Distinct count',
|
|
100
|
+
first: 'First', last: 'Last',
|
|
101
|
+
}
|
package/src/pivot.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createPivotModel,
|
|
8
8
|
filterCollapsedPivotRows,
|
|
9
9
|
pivotAggregators,
|
|
10
|
+
type PivotConfig,
|
|
10
11
|
type PivotRow,
|
|
11
12
|
} from './pivot'
|
|
12
13
|
|
|
@@ -162,15 +163,42 @@ describe('createPivotModel - configuration toggles', () => {
|
|
|
162
163
|
expect(r.rows.find((row) => row.__pivotKind === 'grandTotal')).toBeUndefined()
|
|
163
164
|
})
|
|
164
165
|
|
|
165
|
-
it('
|
|
166
|
+
it('keeps group headers but blanks their values when rowSubtotals=false', () => {
|
|
166
167
|
const r = createPivotModel(facts, {
|
|
167
168
|
rows: ['region', 'salesPerson'],
|
|
168
169
|
cols: ['quarter'],
|
|
169
170
|
values: [{ field: 'amount', agg: 'sum' }],
|
|
170
171
|
rowSubtotals: false,
|
|
171
172
|
})
|
|
172
|
-
//
|
|
173
|
-
|
|
173
|
+
// Group header rows are STILL emitted - the grouping hierarchy is
|
|
174
|
+
// preserved; turning off subtotals never removes a grouping level.
|
|
175
|
+
const groups = r.rows.filter((row) => row.__pivotKind === 'group')
|
|
176
|
+
expect(groups.length).toBeGreaterThan(0)
|
|
177
|
+
expect(groups.map((row) => row.__pivotLabel)).toContain('AMER')
|
|
178
|
+
// ...but each header carries no aggregate values: the value cells are
|
|
179
|
+
// present (so columns line up) and explicitly null.
|
|
180
|
+
for (const g of groups) {
|
|
181
|
+
const valueKeys = Object.keys(g).filter((k) => !k.startsWith('__pivot'))
|
|
182
|
+
expect(valueKeys.length).toBeGreaterThan(0)
|
|
183
|
+
expect(valueKeys.every((k) => (g as Record<string, unknown>)[k] === null)).toBe(true)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('keeps the grand-total value identical whether or not subtotals show', () => {
|
|
188
|
+
const base: PivotConfig<Sale> = {
|
|
189
|
+
rows: ['region', 'salesPerson'],
|
|
190
|
+
cols: ['quarter'],
|
|
191
|
+
values: [{ field: 'amount', agg: 'sum' }],
|
|
192
|
+
}
|
|
193
|
+
const withSub = createPivotModel(facts, { ...base, rowSubtotals: true })
|
|
194
|
+
const noSub = createPivotModel(facts, { ...base, rowSubtotals: false })
|
|
195
|
+
const totalValues = (m: typeof withSub) => {
|
|
196
|
+
const g = m.rows.find((row) => row.__pivotKind === 'grandTotal')!
|
|
197
|
+
return Object.fromEntries(
|
|
198
|
+
Object.entries(g).filter(([k]) => !k.startsWith('__pivot')),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
expect(totalValues(noSub)).toEqual(totalValues(withSub))
|
|
174
202
|
})
|
|
175
203
|
|
|
176
204
|
it('honours a custom rowSort comparator', () => {
|
package/src/pivot.ts
CHANGED
|
@@ -238,7 +238,7 @@ function buildColumnTree<TFeatures extends TableFeatures>(
|
|
|
238
238
|
const headerCol: ColumnDef<TFeatures, PivotRow> = {
|
|
239
239
|
id: '__pivotRowHeader',
|
|
240
240
|
header: rowHeaderLabel,
|
|
241
|
-
|
|
241
|
+
fieldFn: (row) => row.__pivotLabel,
|
|
242
242
|
width: 240,
|
|
243
243
|
}
|
|
244
244
|
|
|
@@ -252,7 +252,7 @@ function buildColumnTree<TFeatures extends TableFeatures>(
|
|
|
252
252
|
return {
|
|
253
253
|
id,
|
|
254
254
|
header: label,
|
|
255
|
-
|
|
255
|
+
fieldFn: (row) => row[id],
|
|
256
256
|
format: value.format,
|
|
257
257
|
width: 130,
|
|
258
258
|
} as ColumnDef<TFeatures, PivotRow>
|
|
@@ -283,7 +283,7 @@ function buildColumnTree<TFeatures extends TableFeatures>(
|
|
|
283
283
|
return {
|
|
284
284
|
id,
|
|
285
285
|
header: label,
|
|
286
|
-
|
|
286
|
+
fieldFn: (row) => row[id],
|
|
287
287
|
format: value.format,
|
|
288
288
|
width: 140,
|
|
289
289
|
} as ColumnDef<TFeatures, PivotRow>
|
|
@@ -349,6 +349,28 @@ function computeRowValues(
|
|
|
349
349
|
return result
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Build the same value-column keys as `computeRowValues`, but with every cell
|
|
354
|
+
* `null`. Used for group HEADER rows when `rowSubtotals` is off: the row keeps
|
|
355
|
+
* its place in the hierarchy (label + expand/collapse) yet shows no aggregate
|
|
356
|
+
* numbers. Cells are `null` rather than absent so consumers can distinguish a
|
|
357
|
+
* deliberately-blank subtotal from a missing column.
|
|
358
|
+
*/
|
|
359
|
+
function blankRowValues(
|
|
360
|
+
colLeafPaths: ReadonlyArray<ReadonlyArray<unknown>>,
|
|
361
|
+
values: ReadonlyArray<PivotValueConfig<unknown>>,
|
|
362
|
+
includeGrandTotalCol: boolean,
|
|
363
|
+
): Record<string, null> {
|
|
364
|
+
const result: Record<string, null> = {}
|
|
365
|
+
for (const colPath of colLeafPaths) {
|
|
366
|
+
for (let i = 0; i < values.length; i += 1) result[leafColumnId(colPath, i)] = null
|
|
367
|
+
}
|
|
368
|
+
if (includeGrandTotalCol) {
|
|
369
|
+
for (let i = 0; i < values.length; i += 1) result[leafColumnId(['__total'], i)] = null
|
|
370
|
+
}
|
|
371
|
+
return result
|
|
372
|
+
}
|
|
373
|
+
|
|
352
374
|
function walkRowTree(
|
|
353
375
|
node: AxisNode,
|
|
354
376
|
out: PivotRow[],
|
|
@@ -387,11 +409,16 @@ function walkRowTree(
|
|
|
387
409
|
return
|
|
388
410
|
}
|
|
389
411
|
|
|
390
|
-
// Group node. Emit
|
|
391
|
-
//
|
|
412
|
+
// Group node. Emit the group HEADER row whenever this node is a real group
|
|
413
|
+
// (not the synthetic root, not the deepest level). The header is emitted
|
|
414
|
+
// regardless of `rowSubtotals` so the grouping hierarchy and the
|
|
415
|
+
// expand/collapse parent chain stay intact; the flag only controls whether
|
|
416
|
+
// the header carries its aggregate (subtotal) values or renders as a
|
|
417
|
+
// label-only row. This matches Excel / AG Grid, where hiding subtotals never
|
|
418
|
+
// removes a grouping level from the row axis.
|
|
392
419
|
const isRealGroup = node.level > 0 && node.level < rowFields.length
|
|
393
420
|
let nextParentId: string | null = parentId
|
|
394
|
-
if (isRealGroup
|
|
421
|
+
if (isRealGroup) {
|
|
395
422
|
const id = `group__${node.pathKey}`
|
|
396
423
|
out.push({
|
|
397
424
|
__pivotId: id,
|
|
@@ -400,13 +427,15 @@ function walkRowTree(
|
|
|
400
427
|
__pivotLabel: String(node.value ?? '(All)'),
|
|
401
428
|
__pivotParentId: parentId,
|
|
402
429
|
__pivotExpandable: true,
|
|
403
|
-
...
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
430
|
+
...(wantSubtotals
|
|
431
|
+
? computeRowValues(
|
|
432
|
+
node.rows as ReadonlyArray<Record<string, unknown>>,
|
|
433
|
+
colLeafPaths,
|
|
434
|
+
colFields,
|
|
435
|
+
values,
|
|
436
|
+
includeGrandTotalCol,
|
|
437
|
+
)
|
|
438
|
+
: blankRowValues(colLeafPaths, values, includeGrandTotalCol)),
|
|
410
439
|
})
|
|
411
440
|
// Children of this group point at this row as their parent so a
|
|
412
441
|
// collapse hides the whole subtree, not just the immediate leaves.
|
package/src/print.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RowData, TableFeatures, SvGridApi } from '@svgrid/grid'
|
|
2
|
-
import {
|
|
2
|
+
import { assertEnterpriseLicensed } from './license'
|
|
3
3
|
import { installSmartShim } from './smart-shim'
|
|
4
4
|
import type { ExportColumn } from './export'
|
|
5
5
|
|
|
@@ -51,7 +51,7 @@ export async function printGrid<
|
|
|
51
51
|
TFeatures extends TableFeatures,
|
|
52
52
|
TData extends RowData,
|
|
53
53
|
>(api: SvGridApi<TFeatures, TData>, opts?: PrintOptions<TData>): Promise<void> {
|
|
54
|
-
|
|
54
|
+
assertEnterpriseLicensed('Print')
|
|
55
55
|
const sourceRows: ReadonlyArray<TData> = opts?.rows ?? api.getDisplayedRows()
|
|
56
56
|
let cols: ReadonlyArray<ExportColumn>
|
|
57
57
|
if (opts?.columns && opts.columns.length > 0) {
|
package/src/revoked.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Revoked license keys. A key in this set throws on use even if it has the
|
|
2
|
-
// valid
|
|
2
|
+
// valid SVENTERPRISE- prefix. Use this to invalidate keys that have been leaked,
|
|
3
3
|
// shared outside their seat count, or issued for trials that have expired.
|
|
4
4
|
//
|
|
5
5
|
// =============================================================================
|
|
@@ -44,6 +44,6 @@
|
|
|
44
44
|
// =============================================================================
|
|
45
45
|
|
|
46
46
|
export const REVOKED_KEYS: ReadonlySet<string> = new Set<string>([
|
|
47
|
-
// '
|
|
48
|
-
// '
|
|
47
|
+
// 'SVENTERPRISE-EVAL-acme-202506-3F7K9P', // example - expired ACME trial
|
|
48
|
+
// 'SVENTERPRISE-LEAKED-globex-202612-X8N4P', // example - key leaked on GitHub
|
|
49
49
|
])
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
9
9
|
import { showUpgradePrompt, dismissUpgradePrompt } from './upgrade-prompt'
|
|
10
10
|
|
|
11
|
-
const CARD_ATTR = 'data
|
|
11
|
+
const CARD_ATTR = 'data-svgrid-enterprise-upgrade'
|
|
12
12
|
|
|
13
13
|
function card(): HTMLElement | null {
|
|
14
14
|
return document.querySelector(`[${CARD_ATTR}]`)
|
package/src/upgrade-prompt.ts
CHANGED
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
// - Zero dependencies, inline styles - no CSS file to import, nothing to
|
|
18
18
|
// bundle, works in any host app regardless of its styling.
|
|
19
19
|
|
|
20
|
-
const CARD_ATTR = 'data
|
|
20
|
+
const CARD_ATTR = 'data-svgrid-enterprise-upgrade'
|
|
21
21
|
const PRICING_URL = 'https://www.svgrid.com/pricing'
|
|
22
22
|
// ?ref=in-app lets us measure how many trials start from this exact prompt
|
|
23
23
|
// vs. the pricing page itself - the whole point of a moment-of-intent CTA.
|
|
24
|
-
const TRIAL_URL = 'https://www.svgrid.com/pricing?ref=in-app&utm_source
|
|
24
|
+
const TRIAL_URL = 'https://www.svgrid.com/pricing?ref=in-app&utm_source=svgrid-enterprise&utm_medium=upgrade-prompt'
|
|
25
25
|
|
|
26
26
|
let shownThisSession = false
|
|
27
27
|
|
|
28
28
|
/** Human-readable labels for the Pro surfaces that gate behind a license. */
|
|
29
|
-
export type
|
|
29
|
+
export type EnterpriseFeatureLabel =
|
|
30
30
|
| 'Export'
|
|
31
31
|
| 'Import'
|
|
32
32
|
| 'Print'
|
|
@@ -38,7 +38,7 @@ export type ProFeatureLabel =
|
|
|
38
38
|
* been shown this session, a card is already up, or we're on the server. Safe
|
|
39
39
|
* to call on every Pro feature invocation - it self-throttles.
|
|
40
40
|
*/
|
|
41
|
-
export function showUpgradePrompt(feature?:
|
|
41
|
+
export function showUpgradePrompt(feature?: EnterpriseFeatureLabel | string): void {
|
|
42
42
|
if (shownThisSession) return
|
|
43
43
|
if (typeof document === 'undefined' || typeof window === 'undefined') return
|
|
44
44
|
if (document.querySelector(`[${CARD_ATTR}]`)) return
|
|
@@ -72,7 +72,7 @@ function close(card: HTMLElement): void {
|
|
|
72
72
|
window.setTimeout(() => card.remove(), 220)
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
function buildCard(feature?:
|
|
75
|
+
function buildCard(feature?: EnterpriseFeatureLabel | string): HTMLElement {
|
|
76
76
|
const card = document.createElement('div')
|
|
77
77
|
card.setAttribute(CARD_ATTR, '1')
|
|
78
78
|
card.setAttribute('role', 'dialog')
|
package/src/watermark.test.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
10
|
import { emitUnlicensedNudge, dismissUnlicensedNudge } from './watermark'
|
|
11
11
|
|
|
12
|
-
const WATERMARK_ATTR = 'data
|
|
12
|
+
const WATERMARK_ATTR = 'data-svgrid-enterprise-watermark'
|
|
13
13
|
|
|
14
14
|
function makeGridRoot(): HTMLElement {
|
|
15
15
|
const el = document.createElement('div')
|