@svgrid/enterprise 1.2.0 → 2.0.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/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
package/src/smart-shim.ts
CHANGED
|
@@ -1,105 +1,107 @@
|
|
|
1
|
-
// Minimal globalThis.Smart shim so the vendored smart.export.js IIFE can
|
|
2
|
-
// register Smart.Utilities.DataExporter without dragging in the rest of the
|
|
3
|
-
// Smart UI runtime. DataExporter calls into Smart.Utilities.{Assign, Core.toDash}
|
|
4
|
-
// at registration; the other namespaces (DateTime, NumberRenderer, Draw,
|
|
5
|
-
// BigNumber, Complex) are guarded by `if (!Smart.Utilities.X) return e;` paths
|
|
6
|
-
// inside the exporter and degrade gracefully when missing.
|
|
7
|
-
//
|
|
8
|
-
// We export a function instead of relying on import-side-effects so callers
|
|
9
|
-
// can deterministically install the shim before the smart.export.js module
|
|
10
|
-
// runs its IIFE.
|
|
11
|
-
|
|
12
|
-
type SmartGlobal = {
|
|
13
|
-
Utilities: {
|
|
14
|
-
Assign(name: string, cls: unknown): void
|
|
15
|
-
Core: {
|
|
16
|
-
toDash(input: string): string
|
|
17
|
-
}
|
|
18
|
-
DataExporter?: new (
|
|
19
|
-
options: Record<string, unknown>,
|
|
20
|
-
groupBy?: ReadonlyArray<string>,
|
|
21
|
-
filterBy?: Record<string, unknown>,
|
|
22
|
-
conditionalFormatting?: unknown,
|
|
23
|
-
) => SmartDataExporterInstance
|
|
24
|
-
[key: string]: unknown
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Shape of the image object Smart's xlsx writer expects from
|
|
30
|
-
* `addImageToCell(...).image`. `base64` may be a full data URL; the
|
|
31
|
-
* exporter strips the prefix.
|
|
32
|
-
*/
|
|
33
|
-
export type SmartImageDescriptor = {
|
|
34
|
-
id: string
|
|
35
|
-
base64: string
|
|
36
|
-
imageType: string
|
|
37
|
-
width: number
|
|
38
|
-
height: number
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type SmartDataExporterInstance = {
|
|
42
|
-
/**
|
|
43
|
-
* Smart looks for these on the INSTANCE (not the constructor opts) at
|
|
44
|
-
* exportData() time. The wrapper sets them after `new DataExporter(...)`.
|
|
45
|
-
*/
|
|
46
|
-
headerContent?: Array<{
|
|
47
|
-
cells: Record<string, unknown>
|
|
48
|
-
style?: Record<string, unknown>
|
|
49
|
-
}>
|
|
50
|
-
footerContent?: Array<{
|
|
51
|
-
cells: Record<string, unknown>
|
|
52
|
-
style?: Record<string, unknown>
|
|
53
|
-
}>
|
|
54
|
-
addImageToCell?: (
|
|
55
|
-
rowIndex: number,
|
|
56
|
-
dataField: string,
|
|
57
|
-
value: unknown,
|
|
58
|
-
rawValue: unknown,
|
|
59
|
-
row: Record<string, unknown>,
|
|
60
|
-
arrIndex: number,
|
|
61
|
-
) => { image: SmartImageDescriptor; value?: unknown } | null
|
|
62
|
-
spreadsheets?: ReadonlyArray<{
|
|
63
|
-
label: string
|
|
64
|
-
dataSource: ReadonlyArray<Record<string, unknown>>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
1
|
+
// Minimal globalThis.Smart shim so the vendored smart.export.js IIFE can
|
|
2
|
+
// register Smart.Utilities.DataExporter without dragging in the rest of the
|
|
3
|
+
// Smart UI runtime. DataExporter calls into Smart.Utilities.{Assign, Core.toDash}
|
|
4
|
+
// at registration; the other namespaces (DateTime, NumberRenderer, Draw,
|
|
5
|
+
// BigNumber, Complex) are guarded by `if (!Smart.Utilities.X) return e;` paths
|
|
6
|
+
// inside the exporter and degrade gracefully when missing.
|
|
7
|
+
//
|
|
8
|
+
// We export a function instead of relying on import-side-effects so callers
|
|
9
|
+
// can deterministically install the shim before the smart.export.js module
|
|
10
|
+
// runs its IIFE.
|
|
11
|
+
|
|
12
|
+
type SmartGlobal = {
|
|
13
|
+
Utilities: {
|
|
14
|
+
Assign(name: string, cls: unknown): void
|
|
15
|
+
Core: {
|
|
16
|
+
toDash(input: string): string
|
|
17
|
+
}
|
|
18
|
+
DataExporter?: new (
|
|
19
|
+
options: Record<string, unknown>,
|
|
20
|
+
groupBy?: ReadonlyArray<string>,
|
|
21
|
+
filterBy?: Record<string, unknown>,
|
|
22
|
+
conditionalFormatting?: unknown,
|
|
23
|
+
) => SmartDataExporterInstance
|
|
24
|
+
[key: string]: unknown
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Shape of the image object Smart's xlsx writer expects from
|
|
30
|
+
* `addImageToCell(...).image`. `base64` may be a full data URL; the
|
|
31
|
+
* exporter strips the prefix.
|
|
32
|
+
*/
|
|
33
|
+
export type SmartImageDescriptor = {
|
|
34
|
+
id: string
|
|
35
|
+
base64: string
|
|
36
|
+
imageType: string
|
|
37
|
+
width: number
|
|
38
|
+
height: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SmartDataExporterInstance = {
|
|
42
|
+
/**
|
|
43
|
+
* Smart looks for these on the INSTANCE (not the constructor opts) at
|
|
44
|
+
* exportData() time. The wrapper sets them after `new DataExporter(...)`.
|
|
45
|
+
*/
|
|
46
|
+
headerContent?: Array<{
|
|
47
|
+
cells: Record<string, unknown>
|
|
48
|
+
style?: Record<string, unknown>
|
|
49
|
+
}>
|
|
50
|
+
footerContent?: Array<{
|
|
51
|
+
cells: Record<string, unknown>
|
|
52
|
+
style?: Record<string, unknown>
|
|
53
|
+
}>
|
|
54
|
+
addImageToCell?: (
|
|
55
|
+
rowIndex: number,
|
|
56
|
+
dataField: string,
|
|
57
|
+
value: unknown,
|
|
58
|
+
rawValue: unknown,
|
|
59
|
+
row: Record<string, unknown>,
|
|
60
|
+
arrIndex: number,
|
|
61
|
+
) => { image: SmartImageDescriptor; value?: unknown } | null
|
|
62
|
+
spreadsheets?: ReadonlyArray<{
|
|
63
|
+
label: string
|
|
64
|
+
dataSource: ReadonlyArray<Record<string, unknown>>
|
|
65
|
+
/** `format` is an optional Smart/Excel number-format token consumed by
|
|
66
|
+
* the xlsx writer's `getXLSXFormat` to keep a column's cells typed. */
|
|
67
|
+
columns: ReadonlyArray<{ dataField: string; label: string; format?: string; width?: number }>
|
|
68
|
+
dataFields: ReadonlyArray<string>
|
|
69
|
+
style?: unknown
|
|
70
|
+
}>
|
|
71
|
+
exportData(
|
|
72
|
+
data: ReadonlyArray<Record<string, unknown>>,
|
|
73
|
+
format: string,
|
|
74
|
+
filename?: string,
|
|
75
|
+
callback?: (result: unknown) => void,
|
|
76
|
+
): unknown
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
declare global {
|
|
80
|
+
interface Window {
|
|
81
|
+
Smart?: SmartGlobal
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let installed = false
|
|
86
|
+
|
|
87
|
+
export function installSmartShim(): void {
|
|
88
|
+
if (installed) return
|
|
89
|
+
if (typeof window === 'undefined') {
|
|
90
|
+
throw new Error('@svgrid/enterprise: installSmartShim() requires a browser environment')
|
|
91
|
+
}
|
|
92
|
+
const existing = window.Smart
|
|
93
|
+
const shim: SmartGlobal = existing ?? ({ Utilities: {} as SmartGlobal['Utilities'] })
|
|
94
|
+
shim.Utilities = shim.Utilities ?? ({} as SmartGlobal['Utilities'])
|
|
95
|
+
if (typeof shim.Utilities.Assign !== 'function') {
|
|
96
|
+
shim.Utilities.Assign = (name: string, cls: unknown) => {
|
|
97
|
+
;(shim.Utilities as Record<string, unknown>)[name] = cls
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!shim.Utilities.Core || typeof shim.Utilities.Core.toDash !== 'function') {
|
|
101
|
+
shim.Utilities.Core = {
|
|
102
|
+
toDash: (s: string) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
window.Smart = shim
|
|
106
|
+
installed = true
|
|
107
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { EntitySchema } from '../schema'
|
|
3
|
+
import { aggregateRows, chartFieldsFromSchema } from './aggregate'
|
|
4
|
+
|
|
5
|
+
const rows = [
|
|
6
|
+
{ id: 1, tier: 'pro', mrr: 100, active: true },
|
|
7
|
+
{ id: 2, tier: 'pro', mrr: 300, active: true },
|
|
8
|
+
{ id: 3, tier: 'free', mrr: 0, active: false },
|
|
9
|
+
{ id: 4, tier: 'enterprise', mrr: 1000, active: true },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('aggregateRows', () => {
|
|
13
|
+
it('sums a measure by dimension, sorted desc', () => {
|
|
14
|
+
expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'sum' })).toEqual([
|
|
15
|
+
{ category: 'enterprise', value: 1000 },
|
|
16
|
+
{ category: 'pro', value: 400 },
|
|
17
|
+
{ category: 'free', value: 0 },
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('counts rows per dimension (measure ignored); ties keep insertion order', () => {
|
|
22
|
+
expect(aggregateRows(rows, { dimension: 'tier', reduce: 'count' })).toEqual([
|
|
23
|
+
{ category: 'pro', value: 2 },
|
|
24
|
+
{ category: 'free', value: 1 },
|
|
25
|
+
{ category: 'enterprise', value: 1 },
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('averages, and handles min / max', () => {
|
|
30
|
+
expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'avg' }).find((b) => b.category === 'pro')).toEqual({ category: 'pro', value: 200 })
|
|
31
|
+
expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'max' }).find((b) => b.category === 'pro')).toEqual({ category: 'pro', value: 300 })
|
|
32
|
+
expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'min' }).find((b) => b.category === 'pro')).toEqual({ category: 'pro', value: 100 })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('groups a boolean dimension', () => {
|
|
36
|
+
const out = aggregateRows(rows, { dimension: 'active', reduce: 'count' })
|
|
37
|
+
expect(out).toEqual([{ category: 'true', value: 3 }, { category: 'false', value: 1 }])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('with no dimension, returns a single grand-total bucket (a KPI)', () => {
|
|
41
|
+
expect(aggregateRows(rows, { reduce: 'count' })).toEqual([{ category: '', value: 4 }])
|
|
42
|
+
expect(aggregateRows(rows, { measure: 'mrr', reduce: 'sum' })).toEqual([{ category: '', value: 1400 }])
|
|
43
|
+
expect(aggregateRows(rows, { measure: 'mrr', reduce: 'max' })).toEqual([{ category: '', value: 1000 }])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('chartFieldsFromSchema', () => {
|
|
48
|
+
const schema: EntitySchema = {
|
|
49
|
+
name: 'customers', idField: 'id',
|
|
50
|
+
fields: [
|
|
51
|
+
{ field: 'id', type: 'text', primaryKey: true },
|
|
52
|
+
{ field: 'name', type: 'text' },
|
|
53
|
+
{ field: 'tier', type: 'enum', options: [] },
|
|
54
|
+
{ field: 'mrr', type: 'number' },
|
|
55
|
+
{ field: 'seats', type: 'number' },
|
|
56
|
+
{ field: 'active', type: 'boolean' },
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('classifies dimensions (enum/boolean/text, non-key) and measures (number)', () => {
|
|
61
|
+
const f = chartFieldsFromSchema(schema)
|
|
62
|
+
expect(f.dimensions).toEqual(['name', 'tier', 'active'])
|
|
63
|
+
expect(f.measures).toEqual(['mrr', 'seats'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('prefers an enum dimension and the first measure as defaults', () => {
|
|
67
|
+
const f = chartFieldsFromSchema(schema)
|
|
68
|
+
expect(f.defaultDimension).toBe('tier')
|
|
69
|
+
expect(f.defaultMeasure).toBe('mrr')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('falls back to boolean then text when there is no enum', () => {
|
|
73
|
+
const noEnum: EntitySchema = {
|
|
74
|
+
name: 't', idField: 'id',
|
|
75
|
+
fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'label', type: 'text' }, { field: 'on', type: 'boolean' }],
|
|
76
|
+
}
|
|
77
|
+
expect(chartFieldsFromSchema(noEnum).defaultDimension).toBe('on')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregation for charts / dashboards: group rows by a dimension and reduce a
|
|
3
|
+
* measure into `{ category, value }` buckets. `aggregateRows` does it in memory;
|
|
4
|
+
* data sources can implement `getAggregate` to push the same shape down to the
|
|
5
|
+
* database (a `GROUP BY`) so it scales past what a page can hold. `SvSchemaChart`
|
|
6
|
+
* consumes either.
|
|
7
|
+
*/
|
|
8
|
+
import type { RowData, ServerFilterModel } from '@svgrid/grid'
|
|
9
|
+
import type { EntitySchema } from '../schema'
|
|
10
|
+
|
|
11
|
+
export type AggregateReduce = 'sum' | 'avg' | 'count' | 'min' | 'max'
|
|
12
|
+
|
|
13
|
+
export type AggregateRequest = {
|
|
14
|
+
/**
|
|
15
|
+
* Field to group by (the chart's category axis). Omit it for a single grand
|
|
16
|
+
* total (a KPI) - the result is one bucket with an empty `category`.
|
|
17
|
+
*/
|
|
18
|
+
dimension?: string
|
|
19
|
+
/** Field to reduce. Ignored for `count`. */
|
|
20
|
+
measure?: string
|
|
21
|
+
reduce: AggregateReduce
|
|
22
|
+
/** Optional filter, same shape the grid emits. */
|
|
23
|
+
filterModel?: ServerFilterModel
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AggregateBucket = { category: string; value: number }
|
|
27
|
+
|
|
28
|
+
/** A data source that can aggregate server-side (e.g. a SQL `GROUP BY`). */
|
|
29
|
+
export type AggregateSource = {
|
|
30
|
+
getAggregate(request: AggregateRequest): Promise<AggregateBucket[]>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const num = (v: unknown) => {
|
|
34
|
+
const n = Number(v)
|
|
35
|
+
return Number.isFinite(n) ? n : 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Group `rows` by `dimension` and reduce `measure`; sorted by value desc. */
|
|
39
|
+
export function aggregateRows<T extends RowData>(
|
|
40
|
+
rows: ReadonlyArray<T>,
|
|
41
|
+
request: AggregateRequest,
|
|
42
|
+
): AggregateBucket[] {
|
|
43
|
+
const { dimension, measure, reduce } = request
|
|
44
|
+
const sum = new Map<string, number>()
|
|
45
|
+
const count = new Map<string, number>()
|
|
46
|
+
const ext = new Map<string, number>() // running min/max
|
|
47
|
+
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
// No dimension -> a single grand-total bucket (KPI).
|
|
50
|
+
const category = dimension ? String(row[dimension] ?? '') : ''
|
|
51
|
+
count.set(category, (count.get(category) ?? 0) + 1)
|
|
52
|
+
if (reduce !== 'count') {
|
|
53
|
+
const v = num(measure ? row[measure] : 0)
|
|
54
|
+
sum.set(category, (sum.get(category) ?? 0) + v)
|
|
55
|
+
if (reduce === 'min') ext.set(category, Math.min(ext.get(category) ?? Infinity, v))
|
|
56
|
+
if (reduce === 'max') ext.set(category, Math.max(ext.get(category) ?? -Infinity, v))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const buckets: AggregateBucket[] = [...count.keys()].map((category) => {
|
|
61
|
+
const c = count.get(category)!
|
|
62
|
+
let value: number
|
|
63
|
+
if (reduce === 'count') value = c
|
|
64
|
+
else if (reduce === 'avg') value = c ? (sum.get(category) ?? 0) / c : 0
|
|
65
|
+
else if (reduce === 'min' || reduce === 'max') value = ext.get(category) ?? 0
|
|
66
|
+
else value = sum.get(category) ?? 0
|
|
67
|
+
return { category, value }
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return buckets.sort((a, b) => b.value - a.value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pick chart-able fields from a schema: **dimensions** to group by (enum /
|
|
75
|
+
* boolean / text, non-key) and **measures** to reduce (number). Defaults favor a
|
|
76
|
+
* low-cardinality dimension (enum, then boolean) and the first numeric measure.
|
|
77
|
+
*/
|
|
78
|
+
export function chartFieldsFromSchema(schema: EntitySchema): {
|
|
79
|
+
dimensions: string[]
|
|
80
|
+
measures: string[]
|
|
81
|
+
defaultDimension: string | null
|
|
82
|
+
defaultMeasure: string | null
|
|
83
|
+
} {
|
|
84
|
+
const nonKey = schema.fields.filter((f) => !f.primaryKey)
|
|
85
|
+
const dimensions = nonKey
|
|
86
|
+
.filter((f) => f.type === 'enum' || f.type === 'boolean' || f.type === 'text')
|
|
87
|
+
.map((f) => f.field)
|
|
88
|
+
const measures = nonKey.filter((f) => f.type === 'number').map((f) => f.field)
|
|
89
|
+
|
|
90
|
+
const preferred =
|
|
91
|
+
nonKey.find((f) => f.type === 'enum')?.field ??
|
|
92
|
+
nonKey.find((f) => f.type === 'boolean')?.field ??
|
|
93
|
+
dimensions[0] ??
|
|
94
|
+
null
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
dimensions,
|
|
98
|
+
measures,
|
|
99
|
+
defaultDimension: preferred,
|
|
100
|
+
defaultMeasure: measures[0] ?? null,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createSupabaseAuth, type AuthState } from './auth-supabase'
|
|
3
|
+
|
|
4
|
+
/** A supabase-js-like auth client with controllable session + spies. */
|
|
5
|
+
function makeClient(initialSession: { user: { id: string; email?: string } } | null = null) {
|
|
6
|
+
let cb: ((event: string, session: unknown) => void) | undefined
|
|
7
|
+
const unsubscribe = vi.fn()
|
|
8
|
+
type AuthResult = { error: { message: string } | null }
|
|
9
|
+
const ok = async (): Promise<AuthResult> => ({ error: null })
|
|
10
|
+
const client = {
|
|
11
|
+
auth: {
|
|
12
|
+
getSession: vi.fn(async () => ({ data: { session: initialSession } })),
|
|
13
|
+
onAuthStateChange: vi.fn((fn: (e: string, s: unknown) => void) => {
|
|
14
|
+
cb = fn
|
|
15
|
+
return { data: { subscription: { unsubscribe } } }
|
|
16
|
+
}),
|
|
17
|
+
signInWithPassword: vi.fn(ok),
|
|
18
|
+
signUp: vi.fn(ok),
|
|
19
|
+
signOut: vi.fn(ok),
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
return { client, unsubscribe, fire: (session: unknown) => cb?.('x', session) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const track = () => {
|
|
26
|
+
const states: AuthState[] = []
|
|
27
|
+
return { states, onChange: (s: AuthState) => states.push(s) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('createSupabaseAuth', () => {
|
|
31
|
+
it('resolves the current session on init', async () => {
|
|
32
|
+
const { client } = makeClient({ user: { id: 'u1', email: 'a@b.co' } })
|
|
33
|
+
const { states, onChange } = track()
|
|
34
|
+
createSupabaseAuth({ client, onChange })
|
|
35
|
+
await vi.waitFor(() => expect(states.at(-1)?.user).toEqual({ id: 'u1', email: 'a@b.co' }))
|
|
36
|
+
expect(states.at(-1)?.loading).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('starts in a loading, signed-out state', () => {
|
|
40
|
+
const { client } = makeClient(null)
|
|
41
|
+
const { states, onChange } = track()
|
|
42
|
+
createSupabaseAuth({ client, onChange })
|
|
43
|
+
expect(states[0]).toEqual({ user: null, loading: true, error: null })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('updates when auth state changes (sign in elsewhere)', async () => {
|
|
47
|
+
const { client, fire } = makeClient(null)
|
|
48
|
+
const { states, onChange } = track()
|
|
49
|
+
createSupabaseAuth({ client, onChange })
|
|
50
|
+
await vi.waitFor(() => expect(states.at(-1)?.loading).toBe(false))
|
|
51
|
+
fire({ user: { id: 'u2', email: 'x@y.co' } })
|
|
52
|
+
expect(states.at(-1)?.user).toEqual({ id: 'u2', email: 'x@y.co' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('signIn calls signInWithPassword and surfaces errors', async () => {
|
|
56
|
+
const { client } = makeClient(null)
|
|
57
|
+
client.auth.signInWithPassword.mockResolvedValueOnce({ error: { message: 'bad creds' } })
|
|
58
|
+
const { states, onChange } = track()
|
|
59
|
+
const auth = createSupabaseAuth({ client, onChange })
|
|
60
|
+
await auth.signIn('a@b.co', 'pw')
|
|
61
|
+
expect(client.auth.signInWithPassword).toHaveBeenCalledWith({ email: 'a@b.co', password: 'pw' })
|
|
62
|
+
expect(states.at(-1)).toMatchObject({ error: 'bad creds', loading: false })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('signUp and signOut call through', async () => {
|
|
66
|
+
const { client } = makeClient(null)
|
|
67
|
+
const auth = createSupabaseAuth({ client, onChange: () => {} })
|
|
68
|
+
await auth.signUp('a@b.co', 'pw')
|
|
69
|
+
await auth.signOut()
|
|
70
|
+
expect(client.auth.signUp).toHaveBeenCalledWith({ email: 'a@b.co', password: 'pw' })
|
|
71
|
+
expect(client.auth.signOut).toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('dispose unsubscribes from auth changes', () => {
|
|
75
|
+
const { client, unsubscribe } = makeClient(null)
|
|
76
|
+
const auth = createSupabaseAuth({ client, onChange: () => {} })
|
|
77
|
+
auth.dispose()
|
|
78
|
+
expect(unsubscribe).toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createSupabaseAuth - a tiny reactive controller over Supabase Auth, so a
|
|
3
|
+
* Studio screen can require a signed-in user. You bring the `supabase-js` client
|
|
4
|
+
* (BYO, no dependency); this tracks the session and exposes sign in / up / out.
|
|
5
|
+
* Pair it with `SvAuthGate` to gate the UI, and with Row-Level Security to scope
|
|
6
|
+
* data per user - auth establishes *who*, RLS enforces *what they can see*.
|
|
7
|
+
*
|
|
8
|
+
* const auth = createSupabaseAuth({ client, onChange: (s) => (authState = s) })
|
|
9
|
+
* await auth.signIn(email, password)
|
|
10
|
+
* // ... auth.signOut(); auth.dispose() on unmount
|
|
11
|
+
*/
|
|
12
|
+
import { nudgeEnterprise } from '../license'
|
|
13
|
+
|
|
14
|
+
export type AuthUser = { id: string; email?: string }
|
|
15
|
+
|
|
16
|
+
export type AuthState = {
|
|
17
|
+
/** The signed-in user, or null. */
|
|
18
|
+
user: AuthUser | null
|
|
19
|
+
/** True during the initial session check or an in-flight auth call. */
|
|
20
|
+
loading: boolean
|
|
21
|
+
/** Last auth error message, or null. */
|
|
22
|
+
error: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Session = { user: { id: string; email?: string } } | null
|
|
26
|
+
|
|
27
|
+
/** The slice of a `supabase-js` client's `auth` this needs. */
|
|
28
|
+
export type SupabaseAuthClientLike = {
|
|
29
|
+
auth: {
|
|
30
|
+
getSession(): Promise<{ data: { session: Session } }>
|
|
31
|
+
onAuthStateChange(cb: (event: string, session: Session) => void): {
|
|
32
|
+
data: { subscription: { unsubscribe(): void } }
|
|
33
|
+
}
|
|
34
|
+
signInWithPassword(c: { email: string; password: string }): Promise<{ error: { message: string } | null }>
|
|
35
|
+
signUp(c: { email: string; password: string }): Promise<{ error: { message: string } | null }>
|
|
36
|
+
signOut(): Promise<{ error: { message: string } | null }>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SupabaseAuthConfig = {
|
|
41
|
+
client: SupabaseAuthClientLike
|
|
42
|
+
onChange: (state: AuthState) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SupabaseAuthController = {
|
|
46
|
+
signIn(email: string, password: string): Promise<void>
|
|
47
|
+
signUp(email: string, password: string): Promise<void>
|
|
48
|
+
signOut(): Promise<void>
|
|
49
|
+
getState(): AuthState
|
|
50
|
+
dispose(): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const userOf = (session: Session): AuthUser | null =>
|
|
54
|
+
session?.user ? { id: session.user.id, email: session.user.email } : null
|
|
55
|
+
|
|
56
|
+
export function createSupabaseAuth(config: SupabaseAuthConfig): SupabaseAuthController {
|
|
57
|
+
nudgeEnterprise('Studio') // soft-gate; never blocks
|
|
58
|
+
let state: AuthState = { user: null, loading: true, error: null }
|
|
59
|
+
const emit = () => config.onChange({ ...state })
|
|
60
|
+
const set = (patch: Partial<AuthState>) => {
|
|
61
|
+
state = { ...state, ...patch }
|
|
62
|
+
emit()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
emit() // initial loading state
|
|
66
|
+
|
|
67
|
+
// Resolve the current session, then keep it in sync.
|
|
68
|
+
config.client.auth.getSession().then(({ data }) => set({ user: userOf(data.session), loading: false }))
|
|
69
|
+
const sub = config.client.auth.onAuthStateChange((_event, session) =>
|
|
70
|
+
set({ user: userOf(session), loading: false }),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const run = async (op: () => Promise<{ error: { message: string } | null }>) => {
|
|
74
|
+
set({ loading: true, error: null })
|
|
75
|
+
const { error } = await op()
|
|
76
|
+
// On success the user arrives via onAuthStateChange; just clear loading.
|
|
77
|
+
set(error ? { loading: false, error: error.message } : { loading: false })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
signIn: (email, password) => run(() => config.client.auth.signInWithPassword({ email, password })),
|
|
82
|
+
signUp: (email, password) => run(() => config.client.auth.signUp({ email, password })),
|
|
83
|
+
signOut: () => run(() => config.client.auth.signOut()),
|
|
84
|
+
getState: () => ({ ...state }),
|
|
85
|
+
dispose: () => sub.data.subscription.unsubscribe(),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { kpiSeries, sparklinePoints, seriesDelta, formatKpiValue } from './dashboard'
|
|
3
|
+
|
|
4
|
+
describe('kpiSeries', () => {
|
|
5
|
+
const rows = [
|
|
6
|
+
{ stage: '2026-01', value: 10 },
|
|
7
|
+
{ stage: '2026-01', value: 5 },
|
|
8
|
+
{ stage: '2026-03', value: 20 },
|
|
9
|
+
{ stage: '2026-02', value: 8 },
|
|
10
|
+
{ stage: '', value: 99 }, // empty bucket key is skipped
|
|
11
|
+
]
|
|
12
|
+
it('buckets by trendField (sorted) and reduces the measure', () => {
|
|
13
|
+
expect(kpiSeries(rows, { trendField: 'stage', measure: 'value', reduce: 'sum' })).toEqual([15, 8, 20])
|
|
14
|
+
})
|
|
15
|
+
it('counts rows per bucket when reduce is count', () => {
|
|
16
|
+
expect(kpiSeries(rows, { trendField: 'stage', reduce: 'count' })).toEqual([2, 1, 1])
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('sparklinePoints', () => {
|
|
21
|
+
it('maps values into a w x h box with the max at the top', () => {
|
|
22
|
+
const pts = sparklinePoints([0, 10], 100, 20, 0).split(' ')
|
|
23
|
+
expect(pts).toHaveLength(2)
|
|
24
|
+
expect(pts[0]).toBe('0.0,20.0') // min -> bottom
|
|
25
|
+
expect(pts[1]).toBe('100.0,0.0') // max -> top, last x at width
|
|
26
|
+
})
|
|
27
|
+
it('is empty for no data', () => {
|
|
28
|
+
expect(sparklinePoints([])).toBe('')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('seriesDelta', () => {
|
|
33
|
+
it('is the percent change from first non-zero to last', () => {
|
|
34
|
+
expect(seriesDelta([100, 150])).toBe(50)
|
|
35
|
+
expect(seriesDelta([0, 100, 50])).toBe(-50) // first non-zero is 100, last is 50
|
|
36
|
+
})
|
|
37
|
+
it('is null when it cannot be computed', () => {
|
|
38
|
+
expect(seriesDelta([5])).toBeNull()
|
|
39
|
+
expect(seriesDelta([0, 0])).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('formatKpiValue', () => {
|
|
44
|
+
it('formats currency / percent / compact / number', () => {
|
|
45
|
+
expect(formatKpiValue(1234.5, 'currency')).toBe('$1,235')
|
|
46
|
+
expect(formatKpiValue(42.5, 'percent')).toBe('42.5%')
|
|
47
|
+
expect(formatKpiValue(12500, 'compact')).toBe('12.5K')
|
|
48
|
+
expect(formatKpiValue(1234.5, 'number')).toBe('1,234.5')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { EntitySchema } from '../schema'
|
|
3
|
+
import { dashboardFromSchema, reduceValue } from './dashboard'
|
|
4
|
+
|
|
5
|
+
const rows = [
|
|
6
|
+
{ id: '1', tier: 'pro', mrr: 100 },
|
|
7
|
+
{ id: '2', tier: 'free', mrr: 0 },
|
|
8
|
+
{ id: '3', tier: 'pro', mrr: 300 },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('reduceValue', () => {
|
|
12
|
+
it('counts rows regardless of measure', () => {
|
|
13
|
+
expect(reduceValue(rows, { reduce: 'count' })).toBe(3)
|
|
14
|
+
expect(reduceValue(rows, { measure: 'mrr', reduce: 'count' })).toBe(3)
|
|
15
|
+
})
|
|
16
|
+
it('sums / averages / min / max a measure', () => {
|
|
17
|
+
expect(reduceValue(rows, { measure: 'mrr', reduce: 'sum' })).toBe(400)
|
|
18
|
+
expect(reduceValue(rows, { measure: 'mrr', reduce: 'avg' })).toBeCloseTo(133.33, 1)
|
|
19
|
+
expect(reduceValue(rows, { measure: 'mrr', reduce: 'min' })).toBe(0)
|
|
20
|
+
expect(reduceValue(rows, { measure: 'mrr', reduce: 'max' })).toBe(300)
|
|
21
|
+
})
|
|
22
|
+
it('is 0 for an empty set', () => {
|
|
23
|
+
expect(reduceValue([], { measure: 'mrr', reduce: 'sum' })).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('dashboardFromSchema', () => {
|
|
28
|
+
const schema: EntitySchema = {
|
|
29
|
+
name: 'customers',
|
|
30
|
+
label: 'Customer',
|
|
31
|
+
fields: [
|
|
32
|
+
{ field: 'id', type: 'text', primaryKey: true },
|
|
33
|
+
{ field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
|
|
34
|
+
{ field: 'mrr', type: 'number' },
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('proposes count + measure KPIs and a chart on the default dimension', () => {
|
|
39
|
+
const { widgets } = dashboardFromSchema(schema)
|
|
40
|
+
const kpis = widgets.filter((w) => w.kind === 'kpi')
|
|
41
|
+
const charts = widgets.filter((w) => w.kind === 'chart')
|
|
42
|
+
expect(kpis.some((w) => w.reduce === 'count')).toBe(true)
|
|
43
|
+
expect(kpis.some((w) => w.kind === 'kpi' && w.measure === 'mrr' && w.reduce === 'sum')).toBe(true)
|
|
44
|
+
expect(charts).toHaveLength(1)
|
|
45
|
+
expect(charts[0]).toMatchObject({ kind: 'chart', dimension: 'tier', measure: 'mrr' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('degrades to a count-only dashboard when there is no measure', () => {
|
|
49
|
+
const noMeasure: EntitySchema = {
|
|
50
|
+
name: 'tags',
|
|
51
|
+
fields: [
|
|
52
|
+
{ field: 'id', type: 'text', primaryKey: true },
|
|
53
|
+
{ field: 'kind', type: 'enum', options: [{ value: 'a', label: 'A' }] },
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
const { widgets } = dashboardFromSchema(noMeasure)
|
|
57
|
+
expect(widgets.filter((w) => w.kind === 'kpi')).toHaveLength(1)
|
|
58
|
+
const chart = widgets.find((w) => w.kind === 'chart')
|
|
59
|
+
expect(chart).toMatchObject({ reduce: 'count' })
|
|
60
|
+
})
|
|
61
|
+
})
|