@svgrid/enterprise 2.3.0 → 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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +6885 -3504
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +5660 -1747
- package/package.json +8 -5
- package/src/SvGridBoard.svelte +4 -1
- package/src/SvGridScheduler.svelte +114 -90
- package/src/ai-export-pdf.dom.test.ts +77 -77
- package/src/ai-export-xlsx.dom.test.ts +90 -90
- package/src/ai-export.dom.test.ts +114 -114
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/expressions/evaluate.ts +9 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +1 -1
- package/src/index.ts +12 -0
- package/src/pivot.test.ts +0 -1
- package/src/schema-designer.ts +1 -1
- package/src/studio/emit-project.test.ts +298 -7
- package/src/studio/emit-project.ts +483 -29
- package/src/studio/emit-schema.ts +20 -10
- package/src/studio/index.ts +34 -0
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/project.test.ts +0 -1
- package/src/studio/project.ts +109 -2
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/live-data.test.ts +1 -1
- package/src/studio/samples/samples.test.ts +268 -268
- package/src/studio/samples/shared.ts +7 -150
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +2 -2
- package/src/studio/ui-components.generated.ts +1643 -106
- package/src/studio/ui-components.ts +742 -641
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.ts +1 -1
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
|
@@ -1,90 +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, 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)
|
|
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
|
+
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,114 +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, 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)
|
|
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
|
-
})
|
|
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
|
+
})
|
package/src/export-ooxml.ts
CHANGED
|
@@ -83,6 +83,10 @@ const XML_ESCAPE: Record<string, string> = {
|
|
|
83
83
|
"'": ''',
|
|
84
84
|
}
|
|
85
85
|
// Control chars that are illegal in XML 1.0 (would make Excel reject the file).
|
|
86
|
+
// XML 1.0 forbids these control characters outright, so they are stripped
|
|
87
|
+
// rather than escaped - a stray \x00 from a data source would otherwise
|
|
88
|
+
// produce a workbook Excel refuses to open.
|
|
89
|
+
// eslint-disable-next-line no-control-regex
|
|
86
90
|
const INVALID_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g
|
|
87
91
|
function esc(s: string): string {
|
|
88
92
|
return s.replace(INVALID_XML, '').replace(/[&<>"']/g, (c) => XML_ESCAPE[c]!)
|
package/src/export-xls.ts
CHANGED
|
@@ -17,6 +17,9 @@ import type { RowData } from '@svgrid/grid'
|
|
|
17
17
|
import type { SerializeOptions } from './export-serialize'
|
|
18
18
|
|
|
19
19
|
// Control chars illegal in XML 1.0 - strip so a stray \x00 can't corrupt the file.
|
|
20
|
+
// See export-ooxml.ts: XML 1.0 has no representation for these at all, so a
|
|
21
|
+
// control character has to be dropped before it reaches the file.
|
|
22
|
+
// eslint-disable-next-line no-control-regex
|
|
20
23
|
const INVALID_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g
|
|
21
24
|
function xmlEscape(s: string): string {
|
|
22
25
|
return s
|
|
@@ -103,6 +103,11 @@ export function evaluateScalar<TData = ExprRow>(
|
|
|
103
103
|
return b === 0 ? NaN : a / b
|
|
104
104
|
case '%':
|
|
105
105
|
return b === 0 ? NaN : a % b
|
|
106
|
+
// Rules are authored at runtime, so an operator outside the union can
|
|
107
|
+
// arrive from parsed JSON. Without this the switch falls out of its
|
|
108
|
+
// case and into `case 'agg'`, reading agg fields off a binary node.
|
|
109
|
+
default:
|
|
110
|
+
return null
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
case 'agg': {
|
|
@@ -123,6 +128,10 @@ export function evaluateScalar<TData = ExprRow>(
|
|
|
123
128
|
return Math.min(...nums)
|
|
124
129
|
case 'max':
|
|
125
130
|
return Math.max(...nums)
|
|
131
|
+
// Same here: falling through to `case 'func'` would call
|
|
132
|
+
// `expr.name.toUpperCase()` on an agg node and throw.
|
|
133
|
+
default:
|
|
134
|
+
return null
|
|
126
135
|
}
|
|
127
136
|
}
|
|
128
137
|
case 'func': {
|
package/src/import.test.ts
CHANGED
|
@@ -250,7 +250,7 @@ describe('validation + commit', () => {
|
|
|
250
250
|
const { api } = fakeApi()
|
|
251
251
|
const r = await importData(api, {
|
|
252
252
|
file: csv, format: 'csv',
|
|
253
|
-
validator: (row,
|
|
253
|
+
validator: (row, _i) => {
|
|
254
254
|
const errs = []
|
|
255
255
|
const price = (row as any).price as number
|
|
256
256
|
if (price < 0) errs.push({ field: 'price', message: 'must be >= 0' })
|
package/src/import.ts
CHANGED
|
@@ -658,7 +658,7 @@ function coerceTyped(raw: string, type: ImportFieldType):
|
|
|
658
658
|
if (iso) return { ok: true, value: `${iso[1]}-${iso[2]}-${iso[3]}` }
|
|
659
659
|
// mm/dd/yyyy or dd/mm/yyyy - we don't try to disambiguate; the
|
|
660
660
|
// demo can declare the locale via its own validator if it cares.
|
|
661
|
-
const slash = trimmed.match(/^(\d{1,2})[
|
|
661
|
+
const slash = trimmed.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})/)
|
|
662
662
|
if (slash) {
|
|
663
663
|
const [, a, b, y] = slash
|
|
664
664
|
// Default to mm/dd/yyyy because it's the dominant locale where
|
package/src/index.ts
CHANGED
|
@@ -201,6 +201,7 @@ export {
|
|
|
201
201
|
type PlanOp,
|
|
202
202
|
type KitDataSourceOptions,
|
|
203
203
|
type KitHandlerOptions,
|
|
204
|
+
type KitScope,
|
|
204
205
|
type KitHandlers,
|
|
205
206
|
type KitMessage,
|
|
206
207
|
type SqlDialect,
|
|
@@ -321,6 +322,17 @@ export {
|
|
|
321
322
|
liveDataSamples,
|
|
322
323
|
getLiveDataSample,
|
|
323
324
|
type SampleApp,
|
|
325
|
+
starterDatasets,
|
|
326
|
+
getStarterDataset,
|
|
327
|
+
type StarterDataset,
|
|
328
|
+
crudSuiteScreens,
|
|
329
|
+
addCrudSuite,
|
|
330
|
+
crudAppFromSchemas,
|
|
331
|
+
type CrudScreenKind,
|
|
332
|
+
type CrudEditingMode,
|
|
333
|
+
type CrudSuiteOptions,
|
|
334
|
+
type CrudAppOptions,
|
|
335
|
+
introspectJson,
|
|
324
336
|
sanitizeStudioProject,
|
|
325
337
|
buildStudioBugReport,
|
|
326
338
|
type BugReport,
|
package/src/pivot.test.ts
CHANGED
package/src/schema-designer.ts
CHANGED
|
@@ -60,7 +60,7 @@ export function updateField(
|
|
|
60
60
|
if (f.field !== fieldName) {
|
|
61
61
|
return makesPrimary && f.primaryKey ? { ...f, primaryKey: false } : f
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
const next: EntityField = { ...f, ...patch }
|
|
64
64
|
if (patch.type && patch.type !== f.type) {
|
|
65
65
|
if (patch.type !== 'enum') delete next.options
|
|
66
66
|
if (patch.type !== 'relation') delete next.relation
|