@svgrid/enterprise 2.2.1 → 2.3.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.
Files changed (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. package/src/ai.ts +0 -1388
@@ -31,6 +31,7 @@
31
31
  popIn,
32
32
  createDismissableLayer,
33
33
  createFocusTrap,
34
+ onScrollOutside,
34
35
  type ColumnDef,
35
36
  type ChartType,
36
37
  type MenuItem,
@@ -692,9 +693,8 @@
692
693
  trap.activate()
693
694
  const layer = createDismissableLayer({ element: () => ctxPanelEl, onDismiss: closeCtx })
694
695
  layer.activate()
695
- const onScroll = () => closeCtx()
696
- window.addEventListener('scroll', onScroll, true)
697
- return () => { layer.release(); trap.release(); window.removeEventListener('scroll', onScroll, true) }
696
+ const offScroll = onScrollOutside(() => ctxPanelEl, closeCtx)
697
+ return () => { layer.release(); trap.release(); offScroll() }
698
698
  })
699
699
 
700
700
  function clearWell(well: Well) {
@@ -19,7 +19,9 @@
19
19
  type Section = { label: string; fields: string[] }
20
20
  type Related = {
21
21
  label: string
22
- schema: EntitySchema
22
+ // Accept any specialized EntitySchema<T> (the type param is invariant); this is
23
+ // pure presentation over the schema's field metadata + plain record rows.
24
+ schema: EntitySchema<any>
23
25
  rows?: ReadonlyArray<Row>
24
26
  /** The child field pointing back at this record. */
25
27
  foreignKey: string
@@ -46,7 +48,9 @@
46
48
  loading = false,
47
49
  height,
48
50
  }: {
49
- schema: EntitySchema
51
+ // Accept any specialized EntitySchema<T> (the type param is invariant); this is
52
+ // pure presentation over the schema's field metadata + plain record rows.
53
+ schema: EntitySchema<any>
50
54
  rows?: ReadonlyArray<Row>
51
55
  /** The specific record to show. Omit to self-drive off `rows`. */
52
56
  row?: Row
@@ -23,7 +23,9 @@
23
23
  loading = false,
24
24
  height,
25
25
  }: {
26
- schema: EntitySchema
26
+ // Accept any specialized EntitySchema<T> (the type param is invariant); this is
27
+ // pure presentation over the schema's field metadata + plain record rows.
28
+ schema: EntitySchema<any>
27
29
  rows?: ReadonlyArray<Row>
28
30
  /** The date / datetime field that places a row on the calendar. */
29
31
  dateField: string
@@ -1,74 +1,77 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- // Capture exactly what exportGrid receives (so we don't need pdfmake / the DOM).
4
- const calls: Array<{ format: string; rows?: unknown[]; groupBy?: string[] }> = []
5
- vi.mock('./export', () => ({
6
- exportGrid: async (_api: unknown, opts: { format: string; rows?: unknown[]; groupBy?: string[] }) => {
7
- calls.push({ format: opts.format, rows: opts.rows as unknown[], groupBy: opts.groupBy })
8
- },
9
- }))
10
-
11
- import { aiExport, setAIProvider, mockAIProvider } from './ai'
12
- import { setLicenseKey } from './license'
13
-
14
- type Order = { country: string; company: string; product: string; quantity: number; price: number }
15
- const rows: Order[] = Array.from({ length: 120 }, (_, i) => ({
16
- country: ['USA', 'UK', 'Germany'][i % 3]!,
17
- company: `Co${i}`,
18
- product: 'Widget',
19
- quantity: 5 + (i % 50),
20
- price: Math.round((10 + ((i * 41) % 480)) * 100) / 100, // spread 10..490
21
- }))
22
-
23
- function makeApi() {
24
- return {
25
- getData: () => rows,
26
- // Simulate the grid returning EVERYTHING (the reactive-filter bug we route around).
27
- getDisplayedRows: () => rows,
28
- getSelectedRows: () => [],
29
- getColumns: () => [
30
- { id: 'country', field: 'country', header: 'Country', visible: true },
31
- { id: 'company', field: 'company', header: 'Company', visible: true },
32
- { id: 'product', field: 'product', header: 'Product', visible: true },
33
- { id: 'quantity', field: 'quantity', header: 'Qty', visible: true },
34
- { id: 'price', field: 'price', header: 'Price', visible: true },
35
- ],
36
- getState: () => ({ grouping: [] }),
37
- clearAllFilters: vi.fn(),
38
- clearSort: vi.fn(),
39
- setFilter: vi.fn(),
40
- setSort: vi.fn(),
41
- setGroupBy: vi.fn(),
42
- } as any
43
- }
44
-
45
- beforeEach(() => {
46
- calls.length = 0
47
- setLicenseKey('SVENTERPRISE-DEV-TEST')
48
- setAIProvider(mockAIProvider)
49
- })
50
-
51
- describe('aiExport - the demo 203 "grouped PDF" query', () => {
52
- it('parses the plan and exports non-empty, filtered, grouped rows', async () => {
53
- const plan = await aiExport(makeApi(), 'export orders over $300 as a grouped PDF by country')
54
-
55
- expect(plan.format).toBe('pdf')
56
- expect(plan.groupBy).toEqual(['country'])
57
- expect(plan.filters).toContainEqual({ field: 'price', operator: 'greaterThan', value: '300' })
58
-
59
- // exportGrid was called with real, filtered rows (not the empty grid view).
60
- expect(calls).toHaveLength(1)
61
- expect(calls[0]!.format).toBe('pdf')
62
- expect(calls[0]!.groupBy).toEqual(['country'])
63
- const exported = calls[0]!.rows as Order[]
64
- expect(exported.length).toBeGreaterThan(0)
65
- expect(exported.every((r) => r.price > 300)).toBe(true)
66
- // Rows are contiguous by country (group order preserved).
67
- const countries = exported.map((r) => r.country)
68
- const firstSeen = [...new Set(countries)]
69
- const contiguous = countries.join('|')
70
- for (const c of firstSeen) {
71
- expect(contiguous).toMatch(new RegExp(`(?:^|\\|)${c}(?:\\|${c})*(?:\\||$)`))
72
- }
73
- })
74
- })
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // Capture exactly what exportGrid receives (so we don't need pdfmake / the DOM).
4
+ const calls: Array<{ format: string; rows?: unknown[]; groupBy?: string[] }> = []
5
+ vi.mock('./export', () => ({
6
+ exportGrid: async (_api: unknown, opts: { format: string; rows?: unknown[]; groupBy?: string[] }) => {
7
+ calls.push({ format: opts.format, rows: opts.rows as unknown[], groupBy: opts.groupBy })
8
+ },
9
+ }))
10
+
11
+ import { aiExport, setAIProvider, mockAIProvider, registerExportProvider } from '@svgrid/grid'
12
+ import { exportGrid } from './export'
13
+ import { setLicenseKey } from './license'
14
+
15
+ type Order = { country: string; company: string; product: string; quantity: number; price: number }
16
+ const rows: Order[] = Array.from({ length: 120 }, (_, i) => ({
17
+ country: ['USA', 'UK', 'Germany'][i % 3]!,
18
+ company: `Co${i}`,
19
+ product: 'Widget',
20
+ quantity: 5 + (i % 50),
21
+ price: Math.round((10 + ((i * 41) % 480)) * 100) / 100, // spread 10..490
22
+ }))
23
+
24
+ function makeApi() {
25
+ return {
26
+ getData: () => rows,
27
+ // Simulate the grid returning EVERYTHING (the reactive-filter bug we route around).
28
+ getDisplayedRows: () => rows,
29
+ getSelectedRows: () => [],
30
+ getColumns: () => [
31
+ { id: 'country', field: 'country', header: 'Country', visible: true },
32
+ { id: 'company', field: 'company', header: 'Company', visible: true },
33
+ { id: 'product', field: 'product', header: 'Product', visible: true },
34
+ { id: 'quantity', field: 'quantity', header: 'Qty', visible: true },
35
+ { id: 'price', field: 'price', header: 'Price', visible: true },
36
+ ],
37
+ getState: () => ({ grouping: [] }),
38
+ clearAllFilters: vi.fn(),
39
+ clearSort: vi.fn(),
40
+ setFilter: vi.fn(),
41
+ setSort: vi.fn(),
42
+ setGroupBy: vi.fn(),
43
+ } as any
44
+ }
45
+
46
+ beforeEach(() => {
47
+ calls.length = 0
48
+ setLicenseKey('SVENTERPRISE-DEV-TEST')
49
+ setAIProvider(mockAIProvider)
50
+ // aiExport delegates to the registered engine - here the mocked exportGrid.
51
+ registerExportProvider(exportGrid as never)
52
+ })
53
+
54
+ describe('aiExport - the demo 203 "grouped PDF" query', () => {
55
+ it('parses the plan and exports non-empty, filtered, grouped rows', async () => {
56
+ const plan = await aiExport(makeApi(), 'export orders over $300 as a grouped PDF by country')
57
+
58
+ expect(plan.format).toBe('pdf')
59
+ expect(plan.groupBy).toEqual(['country'])
60
+ expect(plan.filters).toContainEqual({ field: 'price', operator: 'greaterThan', value: '300' })
61
+
62
+ // exportGrid was called with real, filtered rows (not the empty grid view).
63
+ expect(calls).toHaveLength(1)
64
+ expect(calls[0]!.format).toBe('pdf')
65
+ expect(calls[0]!.groupBy).toEqual(['country'])
66
+ const exported = calls[0]!.rows as Order[]
67
+ expect(exported.length).toBeGreaterThan(0)
68
+ expect(exported.every((r) => r.price > 300)).toBe(true)
69
+ // Rows are contiguous by country (group order preserved).
70
+ const countries = exported.map((r) => r.country)
71
+ const firstSeen = [...new Set(countries)]
72
+ const contiguous = countries.join('|')
73
+ for (const c of firstSeen) {
74
+ expect(contiguous).toMatch(new RegExp(`(?:^|\\|)${c}(?:\\|${c})*(?:\\||$)`))
75
+ }
76
+ })
77
+ })
@@ -1,87 +1,90 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- // Real exportGrid runs (NOT mocked). Capture the xlsx parts / avoid the DOM.
4
- const captured: { parts: Record<string, string> } = { parts: {} }
5
- vi.mock('jszip', () => ({
6
- default: class {
7
- file(p: string, d: string) {
8
- captured.parts[p] = d
9
- }
10
- async generateAsync() {
11
- return new Blob([])
12
- }
13
- },
14
- }))
15
- vi.mock('./export-serialize', async (importOriginal) => {
16
- const actual = (await importOriginal()) as Record<string, unknown>
17
- return { ...actual, downloadBlobFile: () => {}, downloadTextFile: () => {} }
18
- })
19
-
20
- import { aiExport, setAIProvider, mockAIProvider } from './ai'
21
- import { setLicenseKey } from './license'
22
-
23
- // Mirrors the real makeOrders shape - crucially includes `inStock` (boolean)
24
- // and `quantity`/`index` (numbers that never exceed 300), so the mock must
25
- // target `price` for "$300", not a boolean or a count.
26
- type Order = {
27
- id: string
28
- index: number
29
- country: string
30
- company: string
31
- product: string
32
- inStock: boolean
33
- quantity: number
34
- price: number
35
- }
36
- const rows: Order[] = Array.from({ length: 120 }, (_, i) => ({
37
- id: `o${i}`,
38
- index: i + 1,
39
- country: ['USA', 'UK', 'Germany'][i % 3]!,
40
- company: `Co${i}`,
41
- product: 'Widget',
42
- inStock: i % 3 !== 0,
43
- quantity: 5 + (i % 50), // max 54 - never > 300
44
- price: Math.round((10 + ((i * 41) % 480)) * 100) / 100, // spread 10..490
45
- }))
46
-
47
- function makeApi() {
48
- return {
49
- getData: () => rows,
50
- getDisplayedRows: () => rows,
51
- getSelectedRows: () => [],
52
- getColumns: () => [
53
- { id: 'country', field: 'country', header: 'Country', visible: true },
54
- { id: 'company', field: 'company', header: 'Company', visible: true },
55
- { id: 'product', field: 'product', header: 'Product', visible: true },
56
- { id: 'quantity', field: 'quantity', header: 'Qty', visible: true },
57
- { id: 'price', field: 'price', header: 'Price', visible: true, format: { type: 'currency', currency: 'USD' } },
58
- ],
59
- getState: () => ({ grouping: [] }),
60
- getColumnPinning: () => ({ left: [], right: [] }),
61
- clearAllFilters: vi.fn(),
62
- clearSort: vi.fn(),
63
- setFilter: vi.fn(),
64
- setSort: vi.fn(),
65
- setGroupBy: vi.fn(),
66
- } as any
67
- }
68
-
69
- beforeEach(() => {
70
- captured.parts = {}
71
- setLicenseKey('SVENTERPRISE-DEV-TEST')
72
- setAIProvider(mockAIProvider)
73
- })
74
-
75
- describe('aiExport - "export orders over $300" (xlsx default)', () => {
76
- it('produces a non-empty xlsx via the real export path', async () => {
77
- const plan = await aiExport(makeApi(), 'export orders over $300')
78
- expect(plan.format).toBe('xlsx')
79
- expect(plan.filters).toContainEqual({ field: 'price', operator: 'greaterThan', value: '300' })
80
-
81
- const sheet = captured.parts['xl/worksheets/sheet1.xml']
82
- expect(sheet).toBeTruthy()
83
- // header row + at least one data row
84
- const rowCount = (sheet!.match(/<row /g) ?? []).length
85
- expect(rowCount).toBeGreaterThan(1)
86
- })
87
- })
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // Real exportGrid runs (NOT mocked). Capture the xlsx parts / avoid the DOM.
4
+ const captured: { parts: Record<string, string> } = { parts: {} }
5
+ vi.mock('jszip', () => ({
6
+ default: class {
7
+ file(p: string, d: string) {
8
+ captured.parts[p] = d
9
+ }
10
+ async generateAsync() {
11
+ return new Blob([])
12
+ }
13
+ },
14
+ }))
15
+ vi.mock('./export-serialize', async (importOriginal) => {
16
+ const actual = (await importOriginal()) as Record<string, unknown>
17
+ return { ...actual, downloadBlobFile: () => {}, downloadTextFile: () => {} }
18
+ })
19
+
20
+ import { aiExport, setAIProvider, mockAIProvider, registerExportProvider } from '@svgrid/grid'
21
+ import { exportGrid } from './export'
22
+ import { setLicenseKey } from './license'
23
+
24
+ // Mirrors the real makeOrders shape - crucially includes `inStock` (boolean)
25
+ // and `quantity`/`index` (numbers that never exceed 300), so the mock must
26
+ // target `price` for "$300", not a boolean or a count.
27
+ type Order = {
28
+ id: string
29
+ index: number
30
+ country: string
31
+ company: string
32
+ product: string
33
+ inStock: boolean
34
+ quantity: number
35
+ price: number
36
+ }
37
+ const rows: Order[] = Array.from({ length: 120 }, (_, i) => ({
38
+ id: `o${i}`,
39
+ index: i + 1,
40
+ country: ['USA', 'UK', 'Germany'][i % 3]!,
41
+ company: `Co${i}`,
42
+ product: 'Widget',
43
+ inStock: i % 3 !== 0,
44
+ quantity: 5 + (i % 50), // max 54 - never > 300
45
+ price: Math.round((10 + ((i * 41) % 480)) * 100) / 100, // spread 10..490
46
+ }))
47
+
48
+ function makeApi() {
49
+ return {
50
+ getData: () => rows,
51
+ getDisplayedRows: () => rows,
52
+ getSelectedRows: () => [],
53
+ getColumns: () => [
54
+ { id: 'country', field: 'country', header: 'Country', visible: true },
55
+ { id: 'company', field: 'company', header: 'Company', visible: true },
56
+ { id: 'product', field: 'product', header: 'Product', visible: true },
57
+ { id: 'quantity', field: 'quantity', header: 'Qty', visible: true },
58
+ { id: 'price', field: 'price', header: 'Price', visible: true, format: { type: 'currency', currency: 'USD' } },
59
+ ],
60
+ getState: () => ({ grouping: [] }),
61
+ getColumnPinning: () => ({ left: [], right: [] }),
62
+ clearAllFilters: vi.fn(),
63
+ clearSort: vi.fn(),
64
+ setFilter: vi.fn(),
65
+ setSort: vi.fn(),
66
+ setGroupBy: vi.fn(),
67
+ } as any
68
+ }
69
+
70
+ beforeEach(() => {
71
+ captured.parts = {}
72
+ setLicenseKey('SVENTERPRISE-DEV-TEST')
73
+ setAIProvider(mockAIProvider)
74
+ // aiExport is free (grid) and delegates the real write to enterprise's engine.
75
+ registerExportProvider(exportGrid as never)
76
+ })
77
+
78
+ describe('aiExport - "export orders over $300" (xlsx default)', () => {
79
+ it('produces a non-empty xlsx via the real export path', async () => {
80
+ const plan = await aiExport(makeApi(), 'export orders over $300')
81
+ expect(plan.format).toBe('xlsx')
82
+ expect(plan.filters).toContainEqual({ field: 'price', operator: 'greaterThan', value: '300' })
83
+
84
+ const sheet = captured.parts['xl/worksheets/sheet1.xml']
85
+ expect(sheet).toBeTruthy()
86
+ // header row + at least one data row
87
+ const rowCount = (sheet!.match(/<row /g) ?? []).length
88
+ expect(rowCount).toBeGreaterThan(1)
89
+ })
90
+ })
@@ -1,110 +1,114 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- // Capture downloads instead of touching the DOM (aiExport -> exportGrid).
4
- const downloads: Array<{ text: string; filename: string }> = []
5
- vi.mock('./export-serialize', async (importOriginal) => {
6
- const actual = (await importOriginal()) as Record<string, unknown>
7
- return {
8
- ...actual,
9
- downloadTextFile: (text: string, filename: string) => {
10
- downloads.push({ text, filename })
11
- },
12
- }
13
- })
14
-
15
- import { aiExport, aiFindAnomalies, setAIProvider, mockAIProvider } from './ai'
16
- import { setLicenseKey } from './license'
17
-
18
- type Row = { region: string; amount: number; name: string }
19
- const rows: Row[] = [
20
- { region: 'EMEA', amount: 100, name: 'A' },
21
- { region: 'NA', amount: 2000, name: 'B' },
22
- { region: 'EMEA', amount: 50, name: 'C' },
23
- ]
24
-
25
- function makeApi() {
26
- return {
27
- getData: () => rows,
28
- getDisplayedRows: () => rows,
29
- getSelectedRows: () => [],
30
- getColumns: () => [
31
- { id: 'region', field: 'region', header: 'Region', visible: true },
32
- { id: 'amount', field: 'amount', header: 'Amount', visible: true },
33
- { id: 'name', field: 'name', header: 'Name', visible: true },
34
- ],
35
- getState: () => ({ grouping: [] }),
36
- clearAllFilters: vi.fn(),
37
- clearSort: vi.fn(),
38
- setFilter: vi.fn(),
39
- setSort: vi.fn(),
40
- } as any
41
- }
42
-
43
- beforeEach(() => {
44
- downloads.length = 0
45
- setLicenseKey('SVENTERPRISE-DEV-TEST')
46
- setAIProvider(mockAIProvider)
47
- })
48
- afterEach(() => setAIProvider(null))
49
-
50
- describe('aiExport', () => {
51
- it('parses format + groupBy + filters; applies to the grid only when asked', async () => {
52
- const api = makeApi()
53
- const plan = await aiExport(api, 'export deals over 500 grouped by region as a pdf', {
54
- run: false,
55
- apply: true,
56
- })
57
- expect(plan.format).toBe('pdf')
58
- expect(plan.groupBy).toEqual(['region'])
59
- expect(plan.filters).toContainEqual({ field: 'amount', operator: 'greaterThan', value: '500' })
60
- // apply: true -> the grid is mutated
61
- expect(api.clearAllFilters).toHaveBeenCalled()
62
- expect(api.setFilter).toHaveBeenCalledWith('amount', { operator: 'greaterThan', value: '500' })
63
- // run:false -> nothing downloaded
64
- expect(downloads).toHaveLength(0)
65
- })
66
-
67
- it('defaults to xlsx and does NOT touch the grid (apply defaults off)', async () => {
68
- const api = makeApi()
69
- const plan = await aiExport(api, 'just export everything', { run: false })
70
- expect(plan.format).toBe('xlsx')
71
- expect(api.clearAllFilters).not.toHaveBeenCalled()
72
- expect(api.setFilter).not.toHaveBeenCalled()
73
- })
74
-
75
- it('runs the export (downloads) when run is not false', async () => {
76
- const api = makeApi()
77
- await aiExport(api, 'export as csv', { filename: 'report' })
78
- expect(downloads).toHaveLength(1)
79
- expect(downloads[0]!.filename).toBe('report.csv')
80
- })
81
-
82
- it('exports the plan-filtered rows directly (not the grid displayed rows)', async () => {
83
- // getDisplayedRows returns EVERYTHING (as if the grid hadn't re-filtered);
84
- // the export must still contain only rows matching the plan's filter.
85
- const api = makeApi()
86
- await aiExport(api, 'export deals over 500 as csv', { filename: 'big' })
87
- const { text } = downloads[0]!
88
- expect(text).toContain('2000') // amount 2000 > 500 kept
89
- expect(text).not.toContain('100') // amount 100 filtered out
90
- expect(text).not.toContain('50') // amount 50 filtered out
91
- })
92
- })
93
-
94
- describe('aiFindAnomalies', () => {
95
- it('flags a numeric outlier over the scanned rows', async () => {
96
- const api = makeApi()
97
- const result = await aiFindAnomalies(api, {})
98
- expect(result.anomalies.length).toBeGreaterThan(0)
99
- const amountAnomaly = result.anomalies.find((a) => a.field === 'amount')
100
- expect(amountAnomaly?.value).toBe(2000) // the max in the sample
101
- expect(amountAnomaly?.severity).toBe('medium')
102
- expect(result.summary).toBeTruthy()
103
- })
104
-
105
- it('returns empty for an empty selection', async () => {
106
- const api = makeApi()
107
- const result = await aiFindAnomalies(api, { target: { kind: 'selection', rowIndices: [] } })
108
- expect(result.anomalies).toEqual([])
109
- })
110
- })
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // Capture downloads instead of touching the DOM (aiExport -> exportGrid).
4
+ const downloads: Array<{ text: string; filename: string }> = []
5
+ vi.mock('./export-serialize', async (importOriginal) => {
6
+ const actual = (await importOriginal()) as Record<string, unknown>
7
+ return {
8
+ ...actual,
9
+ downloadTextFile: (text: string, filename: string) => {
10
+ downloads.push({ text, filename })
11
+ },
12
+ }
13
+ })
14
+
15
+ import { aiExport, aiFindAnomalies, setAIProvider, mockAIProvider, registerExportProvider } from '@svgrid/grid'
16
+ import { exportGrid } from './export'
17
+ import { setLicenseKey } from './license'
18
+
19
+ type Row = { region: string; amount: number; name: string }
20
+ const rows: Row[] = [
21
+ { region: 'EMEA', amount: 100, name: 'A' },
22
+ { region: 'NA', amount: 2000, name: 'B' },
23
+ { region: 'EMEA', amount: 50, name: 'C' },
24
+ ]
25
+
26
+ function makeApi() {
27
+ return {
28
+ getData: () => rows,
29
+ getDisplayedRows: () => rows,
30
+ getSelectedRows: () => [],
31
+ getColumns: () => [
32
+ { id: 'region', field: 'region', header: 'Region', visible: true },
33
+ { id: 'amount', field: 'amount', header: 'Amount', visible: true },
34
+ { id: 'name', field: 'name', header: 'Name', visible: true },
35
+ ],
36
+ getState: () => ({ grouping: [] }),
37
+ clearAllFilters: vi.fn(),
38
+ clearSort: vi.fn(),
39
+ setFilter: vi.fn(),
40
+ setSort: vi.fn(),
41
+ } as any
42
+ }
43
+
44
+ beforeEach(() => {
45
+ downloads.length = 0
46
+ setLicenseKey('SVENTERPRISE-DEV-TEST')
47
+ setAIProvider(mockAIProvider)
48
+ // aiExport is free (grid) and delegates the WRITE to the registered engine;
49
+ // register enterprise's exportGrid so the download path actually runs.
50
+ registerExportProvider(exportGrid as never)
51
+ })
52
+ afterEach(() => setAIProvider(null))
53
+
54
+ describe('aiExport', () => {
55
+ it('parses format + groupBy + filters; applies to the grid only when asked', async () => {
56
+ const api = makeApi()
57
+ const plan = await aiExport(api, 'export deals over 500 grouped by region as a pdf', {
58
+ run: false,
59
+ apply: true,
60
+ })
61
+ expect(plan.format).toBe('pdf')
62
+ expect(plan.groupBy).toEqual(['region'])
63
+ expect(plan.filters).toContainEqual({ field: 'amount', operator: 'greaterThan', value: '500' })
64
+ // apply: true -> the grid is mutated
65
+ expect(api.clearAllFilters).toHaveBeenCalled()
66
+ expect(api.setFilter).toHaveBeenCalledWith('amount', { operator: 'greaterThan', value: '500' })
67
+ // run:false -> nothing downloaded
68
+ expect(downloads).toHaveLength(0)
69
+ })
70
+
71
+ it('defaults to xlsx and does NOT touch the grid (apply defaults off)', async () => {
72
+ const api = makeApi()
73
+ const plan = await aiExport(api, 'just export everything', { run: false })
74
+ expect(plan.format).toBe('xlsx')
75
+ expect(api.clearAllFilters).not.toHaveBeenCalled()
76
+ expect(api.setFilter).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('runs the export (downloads) when run is not false', async () => {
80
+ const api = makeApi()
81
+ await aiExport(api, 'export as csv', { filename: 'report' })
82
+ expect(downloads).toHaveLength(1)
83
+ expect(downloads[0]!.filename).toBe('report.csv')
84
+ })
85
+
86
+ it('exports the plan-filtered rows directly (not the grid displayed rows)', async () => {
87
+ // getDisplayedRows returns EVERYTHING (as if the grid hadn't re-filtered);
88
+ // the export must still contain only rows matching the plan's filter.
89
+ const api = makeApi()
90
+ await aiExport(api, 'export deals over 500 as csv', { filename: 'big' })
91
+ const { text } = downloads[0]!
92
+ expect(text).toContain('2000') // amount 2000 > 500 kept
93
+ expect(text).not.toContain('100') // amount 100 filtered out
94
+ expect(text).not.toContain('50') // amount 50 filtered out
95
+ })
96
+ })
97
+
98
+ describe('aiFindAnomalies', () => {
99
+ it('flags a numeric outlier over the scanned rows', async () => {
100
+ const api = makeApi()
101
+ const result = await aiFindAnomalies(api, {})
102
+ expect(result.anomalies.length).toBeGreaterThan(0)
103
+ const amountAnomaly = result.anomalies.find((a) => a.field === 'amount')
104
+ expect(amountAnomaly?.value).toBe(2000) // the max in the sample
105
+ expect(amountAnomaly?.severity).toBe('medium')
106
+ expect(result.summary).toBeTruthy()
107
+ })
108
+
109
+ it('returns empty for an empty selection', async () => {
110
+ const api = makeApi()
111
+ const result = await aiFindAnomalies(api, { target: { kind: 'selection', rowIndices: [] } })
112
+ expect(result.anomalies).toEqual([])
113
+ })
114
+ })