@svgrid/grid 1.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/LICENSE +33 -0
- package/README.md +39 -0
- package/dist/FlexRender.svelte +96 -0
- package/dist/FlexRender.svelte.d.ts +49 -0
- package/dist/SvGrid.svelte +8742 -0
- package/dist/SvGrid.svelte.d.ts +381 -0
- package/dist/SvGridChart.svelte +653 -0
- package/dist/SvGridChart.svelte.d.ts +25 -0
- package/dist/SvGridChart.test.d.ts +1 -0
- package/dist/SvGridChart.test.js +62 -0
- package/dist/SvGridDropdown.svelte +675 -0
- package/dist/SvGridDropdown.svelte.d.ts +40 -0
- package/dist/a11y.contract.test.d.ts +1 -0
- package/dist/a11y.contract.test.js +39 -0
- package/dist/a11y.d.ts +38 -0
- package/dist/a11y.js +37 -0
- package/dist/a11y.test.d.ts +1 -0
- package/dist/a11y.test.js +44 -0
- package/dist/cell-formatting.d.ts +17 -0
- package/dist/cell-formatting.js +141 -0
- package/dist/cell-formatting.test.d.ts +1 -0
- package/dist/cell-formatting.test.js +234 -0
- package/dist/chart-export.d.ts +14 -0
- package/dist/chart-export.js +112 -0
- package/dist/chart.d.ts +226 -0
- package/dist/chart.js +732 -0
- package/dist/chart.test.d.ts +1 -0
- package/dist/chart.test.js +289 -0
- package/dist/collaboration.d.ts +74 -0
- package/dist/collaboration.js +98 -0
- package/dist/collaboration.test.d.ts +1 -0
- package/dist/collaboration.test.js +65 -0
- package/dist/conditional-formatting.d.ts +91 -0
- package/dist/conditional-formatting.js +170 -0
- package/dist/conditional-formatting.test.d.ts +1 -0
- package/dist/conditional-formatting.test.js +87 -0
- package/dist/core.coverage.test.d.ts +1 -0
- package/dist/core.coverage.test.js +186 -0
- package/dist/core.d.ts +370 -0
- package/dist/core.js +568 -0
- package/dist/core.performance.test.d.ts +1 -0
- package/dist/core.performance.test.js +29 -0
- package/dist/createGrid.svelte.d.ts +6 -0
- package/dist/createGrid.svelte.js +17 -0
- package/dist/createGrid.test.d.ts +1 -0
- package/dist/createGrid.test.js +9 -0
- package/dist/createGridState.svelte.d.ts +3 -0
- package/dist/createGridState.svelte.js +13 -0
- package/dist/editors/cell-editors.d.ts +23 -0
- package/dist/editors/cell-editors.js +97 -0
- package/dist/editors/cell-editors.test.d.ts +1 -0
- package/dist/editors/cell-editors.test.js +75 -0
- package/dist/fill-patterns.d.ts +30 -0
- package/dist/fill-patterns.js +207 -0
- package/dist/fill-patterns.test.d.ts +1 -0
- package/dist/fill-patterns.test.js +81 -0
- package/dist/filtering/excel-filters.d.ts +26 -0
- package/dist/filtering/excel-filters.js +64 -0
- package/dist/filtering/excel-filters.test.d.ts +1 -0
- package/dist/filtering/excel-filters.test.js +116 -0
- package/dist/filtering/locale-filter.test.d.ts +13 -0
- package/dist/filtering/locale-filter.test.js +189 -0
- package/dist/flex-render.d.ts +1 -0
- package/dist/flex-render.js +2 -0
- package/dist/flex-render.test.d.ts +1 -0
- package/dist/flex-render.test.js +142 -0
- package/dist/group-aggregate.test.d.ts +1 -0
- package/dist/group-aggregate.test.js +32 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +27 -0
- package/dist/keyboard.d.ts +8 -0
- package/dist/keyboard.js +91 -0
- package/dist/keyboard.test.d.ts +1 -0
- package/dist/keyboard.test.js +27 -0
- package/dist/merge-objects.d.ts +9 -0
- package/dist/merge-objects.js +28 -0
- package/dist/named-views.d.ts +42 -0
- package/dist/named-views.js +80 -0
- package/dist/named-views.test.d.ts +1 -0
- package/dist/named-views.test.js +57 -0
- package/dist/new-features.test.d.ts +1 -0
- package/dist/new-features.test.js +183 -0
- package/dist/render-component.d.ts +13 -0
- package/dist/render-component.js +14 -0
- package/dist/render-component.test.d.ts +1 -0
- package/dist/render-component.test.js +41 -0
- package/dist/server-data-source.d.ts +76 -0
- package/dist/server-data-source.js +83 -0
- package/dist/server-data-source.test.d.ts +1 -0
- package/dist/server-data-source.test.js +81 -0
- package/dist/sparkline.d.ts +56 -0
- package/dist/sparkline.js +97 -0
- package/dist/sparkline.test.d.ts +1 -0
- package/dist/sparkline.test.js +52 -0
- package/dist/static-functions.d.ts +1 -0
- package/dist/static-functions.js +1 -0
- package/dist/subscribe.d.ts +9 -0
- package/dist/subscribe.js +31 -0
- package/dist/subscribe.test.d.ts +1 -0
- package/dist/subscribe.test.js +93 -0
- package/dist/sv-grid-scrollbar.d.ts +1 -0
- package/dist/sv-grid-scrollbar.js +310 -0
- package/dist/svgrid-wrapper.types.d.ts +341 -0
- package/dist/svgrid-wrapper.types.js +1 -0
- package/dist/svgrid.api-extensions.test.d.ts +14 -0
- package/dist/svgrid.api-extensions.test.js +334 -0
- package/dist/svgrid.api.test.d.ts +15 -0
- package/dist/svgrid.api.test.js +169 -0
- package/dist/svgrid.behavior.test.d.ts +15 -0
- package/dist/svgrid.behavior.test.js +605 -0
- package/dist/svgrid.column-reorder.test.d.ts +15 -0
- package/dist/svgrid.column-reorder.test.js +224 -0
- package/dist/svgrid.features.test.d.ts +1 -0
- package/dist/svgrid.features.test.js +129 -0
- package/dist/svgrid.interaction.test.d.ts +9 -0
- package/dist/svgrid.interaction.test.js +318 -0
- package/dist/svgrid.locale-filtering.test.d.ts +15 -0
- package/dist/svgrid.locale-filtering.test.js +261 -0
- package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
- package/dist/svgrid.new-features.wrapper.test.js +136 -0
- package/dist/svgrid.row-pinning.test.d.ts +14 -0
- package/dist/svgrid.row-pinning.test.js +250 -0
- package/dist/svgrid.set-filter.test.d.ts +17 -0
- package/dist/svgrid.set-filter.test.js +268 -0
- package/dist/svgrid.wrapper.test.d.ts +1 -0
- package/dist/svgrid.wrapper.test.js +33 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +29 -0
- package/dist/transaction.test.d.ts +1 -0
- package/dist/transaction.test.js +89 -0
- package/dist/virtualization/column-virtualizer.d.ts +20 -0
- package/dist/virtualization/column-virtualizer.js +19 -0
- package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
- package/dist/virtualization/column-virtualizer.test.js +25 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
- package/dist/virtualization/types.d.ts +28 -0
- package/dist/virtualization/types.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +13 -0
- package/dist/virtualization/virtualizer.js +232 -0
- package/dist/virtualization/virtualizer.test.d.ts +1 -0
- package/dist/virtualization/virtualizer.test.js +41 -0
- package/package.json +69 -0
- package/src/FlexRender.svelte +96 -0
- package/src/SvGrid.svelte +8742 -0
- package/src/SvGridChart.svelte +653 -0
- package/src/SvGridChart.test.ts +69 -0
- package/src/SvGridDropdown.svelte +675 -0
- package/src/a11y.contract.test.ts +49 -0
- package/src/a11y.test.ts +59 -0
- package/src/a11y.ts +59 -0
- package/src/cell-formatting.test.ts +273 -0
- package/src/cell-formatting.ts +169 -0
- package/src/chart-export.ts +144 -0
- package/src/chart.test.ts +311 -0
- package/src/chart.ts +934 -0
- package/src/collaboration.test.ts +74 -0
- package/src/collaboration.ts +161 -0
- package/src/conditional-formatting.test.ts +107 -0
- package/src/conditional-formatting.ts +239 -0
- package/src/core.coverage.test.ts +230 -0
- package/src/core.performance.test.ts +30 -0
- package/src/core.ts +991 -0
- package/src/createGrid.svelte.ts +42 -0
- package/src/createGrid.test.ts +10 -0
- package/src/createGridState.svelte.ts +17 -0
- package/src/editors/cell-editors.test.ts +80 -0
- package/src/editors/cell-editors.ts +127 -0
- package/src/fill-patterns.test.ts +93 -0
- package/src/fill-patterns.ts +229 -0
- package/src/filtering/excel-filters.test.ts +131 -0
- package/src/filtering/excel-filters.ts +101 -0
- package/src/filtering/locale-filter.test.ts +220 -0
- package/src/flex-render.test.ts +143 -0
- package/src/flex-render.ts +3 -0
- package/src/group-aggregate.test.ts +48 -0
- package/src/index.ts +159 -0
- package/src/keyboard.test.ts +59 -0
- package/src/keyboard.ts +97 -0
- package/src/merge-objects.ts +48 -0
- package/src/named-views.test.ts +66 -0
- package/src/named-views.ts +120 -0
- package/src/new-features.test.ts +217 -0
- package/src/render-component.test.ts +51 -0
- package/src/render-component.ts +28 -0
- package/src/server-data-source.test.ts +88 -0
- package/src/server-data-source.ts +163 -0
- package/src/sparkline.test.ts +59 -0
- package/src/sparkline.ts +164 -0
- package/src/static-functions.ts +11 -0
- package/src/subscribe.test.ts +103 -0
- package/src/subscribe.ts +38 -0
- package/src/sv-grid-scrollbar.ts +347 -0
- package/src/svgrid-wrapper.types.ts +382 -0
- package/src/svgrid.api-extensions.test.ts +362 -0
- package/src/svgrid.api.test.ts +192 -0
- package/src/svgrid.behavior.test.ts +657 -0
- package/src/svgrid.column-reorder.test.ts +234 -0
- package/src/svgrid.features.test.ts +157 -0
- package/src/svgrid.interaction.test.ts +355 -0
- package/src/svgrid.locale-filtering.test.ts +259 -0
- package/src/svgrid.new-features.wrapper.test.ts +164 -0
- package/src/svgrid.row-pinning.test.ts +266 -0
- package/src/svgrid.set-filter.test.ts +270 -0
- package/src/svgrid.wrapper.test.ts +35 -0
- package/src/test-setup.ts +37 -0
- package/src/transaction.test.ts +100 -0
- package/src/virtualization/column-virtualizer.test.ts +27 -0
- package/src/virtualization/column-virtualizer.ts +30 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
- package/src/virtualization/types.ts +30 -0
- package/src/virtualization/virtualizer.test.ts +47 -0
- package/src/virtualization/virtualizer.ts +270 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the headless-engine features shipped alongside the wrapper
|
|
3
|
+
* additions (`getRowId`, the `between` filter operator). Wrapper-level
|
|
4
|
+
* additions are covered by the source-level smoke tests in
|
|
5
|
+
* `svgrid.new-features.wrapper.test.ts` - jsdom + Svelte 5 mount is
|
|
6
|
+
* brittle for tests that only need to confirm props are read.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import {
|
|
10
|
+
columnFilteringFeature,
|
|
11
|
+
createCoreRowModel,
|
|
12
|
+
createSvGrid,
|
|
13
|
+
rowSortingFeature,
|
|
14
|
+
tableFeatures,
|
|
15
|
+
type ColumnDef,
|
|
16
|
+
} from './index'
|
|
17
|
+
import { applyExcelFilter } from './filtering/excel-filters'
|
|
18
|
+
|
|
19
|
+
type Row = { id: string; name: string; age: number }
|
|
20
|
+
|
|
21
|
+
const data: Row[] = [
|
|
22
|
+
{ id: 'ada', name: 'Ada Lovelace', age: 36 },
|
|
23
|
+
{ id: 'linus', name: 'Linus Torvalds', age: 54 },
|
|
24
|
+
{ id: 'grace', name: 'Grace Hopper', age: 85 },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const columns: ColumnDef<{ rowSortingFeature: { key: string } }, Row>[] = [
|
|
28
|
+
{ field: 'id', header: 'ID' },
|
|
29
|
+
{ field: 'name', header: 'Name' },
|
|
30
|
+
{ field: 'age', header: 'Age' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const features = tableFeatures({ rowSortingFeature })
|
|
34
|
+
|
|
35
|
+
describe('createSvGrid - getRowId', () => {
|
|
36
|
+
it('falls back to the array index as id when no getRowId is provided', () => {
|
|
37
|
+
const grid = createSvGrid({
|
|
38
|
+
_features: features,
|
|
39
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
40
|
+
columns,
|
|
41
|
+
data,
|
|
42
|
+
})
|
|
43
|
+
const rows = grid.getRowModel().rows
|
|
44
|
+
expect(rows.map((r) => r.id)).toEqual(['0', '1', '2'])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('uses the result of getRowId(row, index) as the row id', () => {
|
|
48
|
+
const grid = createSvGrid<typeof features, Row>({
|
|
49
|
+
_features: features,
|
|
50
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
51
|
+
columns,
|
|
52
|
+
data,
|
|
53
|
+
getRowId: (row) => row.id,
|
|
54
|
+
})
|
|
55
|
+
const rows = grid.getRowModel().rows
|
|
56
|
+
expect(rows.map((r) => r.id)).toEqual(['ada', 'linus', 'grace'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('passes the row index to getRowId', () => {
|
|
60
|
+
const seen: Array<{ id: string; index: number }> = []
|
|
61
|
+
createSvGrid<typeof features, Row>({
|
|
62
|
+
_features: features,
|
|
63
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
64
|
+
columns,
|
|
65
|
+
data,
|
|
66
|
+
getRowId: (row, index) => {
|
|
67
|
+
seen.push({ id: row.id, index })
|
|
68
|
+
return row.id
|
|
69
|
+
},
|
|
70
|
+
}).getRowModel().rows
|
|
71
|
+
expect(seen).toEqual([
|
|
72
|
+
{ id: 'ada', index: 0 },
|
|
73
|
+
{ id: 'linus', index: 1 },
|
|
74
|
+
{ id: 'grace', index: 2 },
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('preserves the same id across `getRowModel()` invocations (stable identity)', () => {
|
|
79
|
+
const grid = createSvGrid<typeof features, Row>({
|
|
80
|
+
_features: features,
|
|
81
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
82
|
+
columns,
|
|
83
|
+
data,
|
|
84
|
+
getRowId: (row) => `r:${row.id}`,
|
|
85
|
+
})
|
|
86
|
+
const first = grid.getRowModel().rows.map((r) => r.id)
|
|
87
|
+
const second = grid.getRowModel().rows.map((r) => r.id)
|
|
88
|
+
expect(first).toEqual(second)
|
|
89
|
+
expect(first).toEqual(['r:ada', 'r:linus', 'r:grace'])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('applyExcelFilter - between operator', () => {
|
|
94
|
+
it('returns true when the value is inside the inclusive range', () => {
|
|
95
|
+
expect(applyExcelFilter(50, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is inclusive at the lower bound', () => {
|
|
99
|
+
expect(applyExcelFilter(18, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('is inclusive at the upper bound', () => {
|
|
103
|
+
expect(applyExcelFilter(65, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns false when the value is below the range', () => {
|
|
107
|
+
expect(applyExcelFilter(17, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns false when the value is above the range', () => {
|
|
111
|
+
expect(applyExcelFilter(66, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('parses string endpoints as numbers (the wrapper hands strings through)', () => {
|
|
115
|
+
expect(applyExcelFilter('30', { id: 'age', operator: 'between', value: '18', valueTo: '65' })).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('works with ISO date strings via lexicographic compare', () => {
|
|
119
|
+
expect(applyExcelFilter('2026-06-15', {
|
|
120
|
+
id: 'd', operator: 'between', value: '2026-01-01', valueTo: '2026-12-31',
|
|
121
|
+
})).toBe(true)
|
|
122
|
+
expect(applyExcelFilter('2025-12-31', {
|
|
123
|
+
id: 'd', operator: 'between', value: '2026-01-01', valueTo: '2026-12-31',
|
|
124
|
+
})).toBe(false)
|
|
125
|
+
expect(applyExcelFilter('2027-01-01', {
|
|
126
|
+
id: 'd', operator: 'between', value: '2026-01-01', valueTo: '2026-12-31',
|
|
127
|
+
})).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('ColumnDef.sortable + ColumnDef.filterable', () => {
|
|
132
|
+
const features = tableFeatures({
|
|
133
|
+
rowSortingFeature,
|
|
134
|
+
columnFilteringFeature,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
function columnsWith(opts: {
|
|
138
|
+
nameSortable?: boolean
|
|
139
|
+
nameFilterable?: boolean
|
|
140
|
+
ageSortable?: boolean
|
|
141
|
+
}): ColumnDef<typeof features, Row>[] {
|
|
142
|
+
return [
|
|
143
|
+
{ field: 'id', header: 'ID' },
|
|
144
|
+
{ field: 'name', header: 'Name', sortable: opts.nameSortable, filterable: opts.nameFilterable },
|
|
145
|
+
{ field: 'age', header: 'Age', sortable: opts.ageSortable },
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it('getCanSort() returns true by default when rowSortingFeature is on', () => {
|
|
150
|
+
const grid = createSvGrid({
|
|
151
|
+
_features: features,
|
|
152
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
153
|
+
data,
|
|
154
|
+
columns: columnsWith({}),
|
|
155
|
+
})
|
|
156
|
+
const name = grid.getAllColumns().find((c) => c.id === 'name')
|
|
157
|
+
expect(name?.getCanSort()).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('getCanSort() returns false when columnDef.sortable === false', () => {
|
|
161
|
+
const grid = createSvGrid({
|
|
162
|
+
_features: features,
|
|
163
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
164
|
+
data,
|
|
165
|
+
columns: columnsWith({ nameSortable: false }),
|
|
166
|
+
})
|
|
167
|
+
const name = grid.getAllColumns().find((c) => c.id === 'name')
|
|
168
|
+
const age = grid.getAllColumns().find((c) => c.id === 'age')
|
|
169
|
+
expect(name?.getCanSort()).toBe(false)
|
|
170
|
+
expect(age?.getCanSort()).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('getCanSort() still returns false even with sortable: true if the feature is OFF', () => {
|
|
174
|
+
const noSortFeatures = tableFeatures({ columnFilteringFeature })
|
|
175
|
+
const grid = createSvGrid({
|
|
176
|
+
_features: noSortFeatures,
|
|
177
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
178
|
+
data,
|
|
179
|
+
columns: [
|
|
180
|
+
{ field: 'name', header: 'Name', sortable: true },
|
|
181
|
+
] as ColumnDef<typeof noSortFeatures, Row>[],
|
|
182
|
+
})
|
|
183
|
+
const name = grid.getAllColumns().find((c) => c.id === 'name')
|
|
184
|
+
expect(name?.getCanSort()).toBe(false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('getCanFilter() returns false when columnDef.filterable === false', () => {
|
|
188
|
+
const grid = createSvGrid({
|
|
189
|
+
_features: features,
|
|
190
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
191
|
+
data,
|
|
192
|
+
columns: columnsWith({ nameFilterable: false }),
|
|
193
|
+
})
|
|
194
|
+
const name = grid.getAllColumns().find((c) => c.id === 'name')
|
|
195
|
+
const age = grid.getAllColumns().find((c) => c.id === 'age')
|
|
196
|
+
expect(name?.getCanFilter()).toBe(false)
|
|
197
|
+
expect(age?.getCanFilter()).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('sortable + filterable are independent', () => {
|
|
201
|
+
const grid = createSvGrid({
|
|
202
|
+
_features: features,
|
|
203
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
204
|
+
data,
|
|
205
|
+
columns: [
|
|
206
|
+
{ field: 'name', header: 'Name', sortable: false, filterable: true },
|
|
207
|
+
{ field: 'age', header: 'Age', sortable: true, filterable: false },
|
|
208
|
+
] as ColumnDef<typeof features, Row>[],
|
|
209
|
+
})
|
|
210
|
+
const name = grid.getAllColumns().find((c) => c.id === 'name')
|
|
211
|
+
const age = grid.getAllColumns().find((c) => c.id === 'age')
|
|
212
|
+
expect(name?.getCanSort()).toBe(false)
|
|
213
|
+
expect(name?.getCanFilter()).toBe(true)
|
|
214
|
+
expect(age?.getCanSort()).toBe(true)
|
|
215
|
+
expect(age?.getCanFilter()).toBe(false)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the renderComponent / renderSnippet factories.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
RenderComponentConfig,
|
|
7
|
+
RenderSnippetConfig,
|
|
8
|
+
renderComponent,
|
|
9
|
+
renderSnippet,
|
|
10
|
+
} from './render-component'
|
|
11
|
+
|
|
12
|
+
describe('renderComponent', () => {
|
|
13
|
+
it('returns a RenderComponentConfig instance', () => {
|
|
14
|
+
const FakeComponent = (() => null) as any
|
|
15
|
+
const config = renderComponent(FakeComponent)
|
|
16
|
+
expect(config).toBeInstanceOf(RenderComponentConfig)
|
|
17
|
+
expect(config.component).toBe(FakeComponent)
|
|
18
|
+
expect(config.props).toBeUndefined()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('captures the props payload as-is', () => {
|
|
22
|
+
const FakeComponent = (() => null) as any
|
|
23
|
+
const config = renderComponent(FakeComponent, { id: 1, label: 'Hello' } as any)
|
|
24
|
+
expect(config.props).toEqual({ id: 1, label: 'Hello' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('reuses the constructor instance fields', () => {
|
|
28
|
+
const FakeComponent = (() => null) as any
|
|
29
|
+
const a = renderComponent(FakeComponent, { x: 1 } as any)
|
|
30
|
+
const b = renderComponent(FakeComponent, { x: 1 } as any)
|
|
31
|
+
// Different instances - the factory does not memoize.
|
|
32
|
+
expect(a).not.toBe(b)
|
|
33
|
+
expect(a.component).toBe(b.component)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('renderSnippet', () => {
|
|
38
|
+
it('returns a RenderSnippetConfig instance with the captured snippet + params', () => {
|
|
39
|
+
const fakeSnippet = (() => null) as any
|
|
40
|
+
const config = renderSnippet(fakeSnippet, { value: 42 })
|
|
41
|
+
expect(config).toBeInstanceOf(RenderSnippetConfig)
|
|
42
|
+
expect(config.snippet).toBe(fakeSnippet)
|
|
43
|
+
expect(config.params).toEqual({ value: 42 })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('allows an undefined params payload', () => {
|
|
47
|
+
const fakeSnippet = (() => null) as any
|
|
48
|
+
const config = renderSnippet(fakeSnippet)
|
|
49
|
+
expect(config.params).toBeUndefined()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Component, ComponentProps, Snippet } from 'svelte'
|
|
2
|
+
|
|
3
|
+
export class RenderComponentConfig<TComponent extends Component> {
|
|
4
|
+
constructor(
|
|
5
|
+
public component: TComponent,
|
|
6
|
+
public props?: ComponentProps<TComponent> | Record<string, never>,
|
|
7
|
+
) {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class RenderSnippetConfig<TProps> {
|
|
11
|
+
constructor(
|
|
12
|
+
public snippet: Snippet<[TProps]>,
|
|
13
|
+
public params?: TProps,
|
|
14
|
+
) {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const renderComponent = <
|
|
18
|
+
TComponent extends Component<any>,
|
|
19
|
+
TProps extends ComponentProps<TComponent>,
|
|
20
|
+
>(
|
|
21
|
+
component: TComponent,
|
|
22
|
+
props?: TProps,
|
|
23
|
+
) => new RenderComponentConfig(component, props)
|
|
24
|
+
|
|
25
|
+
export const renderSnippet = <TProps>(
|
|
26
|
+
snippet: Snippet<[TProps]>,
|
|
27
|
+
params?: TProps,
|
|
28
|
+
) => new RenderSnippetConfig(snippet, params)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createServerDataSource, type ServerDataSource } from './server-data-source'
|
|
3
|
+
|
|
4
|
+
type Row = { id: number }
|
|
5
|
+
|
|
6
|
+
function makeSource(total = 250, delay = 0): ServerDataSource<Row> & { calls: number } {
|
|
7
|
+
const all: Row[] = Array.from({ length: total }, (_, i) => ({ id: i }))
|
|
8
|
+
return {
|
|
9
|
+
calls: 0,
|
|
10
|
+
async getRows(req) {
|
|
11
|
+
this.calls += 1
|
|
12
|
+
if (delay) await new Promise((r) => setTimeout(r, delay))
|
|
13
|
+
// server applies sort: reverse when desc on 'id'
|
|
14
|
+
let rows = all
|
|
15
|
+
if (req.sortModel[0]?.id === 'id' && req.sortModel[0]?.desc) rows = [...all].reverse()
|
|
16
|
+
return { rows: rows.slice(req.startRow, req.endRow), rowCount: total }
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const flush = () => new Promise((r) => setTimeout(r, 0))
|
|
22
|
+
|
|
23
|
+
describe('createServerDataSource', () => {
|
|
24
|
+
it('fetches a page and reports total + pageCount', async () => {
|
|
25
|
+
const src = makeSource(250)
|
|
26
|
+
const states: any[] = []
|
|
27
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
|
|
28
|
+
ctl.refresh()
|
|
29
|
+
await flush()
|
|
30
|
+
const last = ctl.getState()
|
|
31
|
+
expect(last.rows).toHaveLength(50)
|
|
32
|
+
expect(last.total).toBe(250)
|
|
33
|
+
expect(last.pageCount).toBe(5)
|
|
34
|
+
expect(last.loading).toBe(false)
|
|
35
|
+
// emitted a loading=true then loading=false
|
|
36
|
+
expect(states.some((s) => s.loading)).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('setPage fetches the right slice', async () => {
|
|
40
|
+
const src = makeSource(250)
|
|
41
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
42
|
+
ctl.setPage(2)
|
|
43
|
+
await flush()
|
|
44
|
+
expect(ctl.getState().rows[0]).toEqual({ id: 100 })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('setSort resets to page 0 and re-fetches', async () => {
|
|
48
|
+
const src = makeSource(250)
|
|
49
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
50
|
+
ctl.setPage(2)
|
|
51
|
+
await flush()
|
|
52
|
+
ctl.setSort([{ id: 'id', desc: true }])
|
|
53
|
+
await flush()
|
|
54
|
+
const s = ctl.getState()
|
|
55
|
+
expect(s.pageIndex).toBe(0)
|
|
56
|
+
expect(s.rows[0]).toEqual({ id: 249 }) // reversed
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('ignores a stale response when a newer request supersedes it', async () => {
|
|
60
|
+
// slow source: first call (page 0) resolves AFTER the second (page 1)
|
|
61
|
+
const all = Array.from({ length: 100 }, (_, i) => ({ id: i }))
|
|
62
|
+
let call = 0
|
|
63
|
+
const src: ServerDataSource<Row> = {
|
|
64
|
+
getRows: vi.fn(async (req) => {
|
|
65
|
+
call += 1
|
|
66
|
+
const delay = call === 1 ? 40 : 5 // first call is slow
|
|
67
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
68
|
+
return { rows: all.slice(req.startRow, req.endRow), rowCount: 100 }
|
|
69
|
+
}),
|
|
70
|
+
}
|
|
71
|
+
const ctl = createServerDataSource(src, { pageSize: 10, onChange: () => {} })
|
|
72
|
+
ctl.refresh() // page 0 (slow)
|
|
73
|
+
ctl.setPage(3) // page 30 (fast) - should win
|
|
74
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
75
|
+
expect(ctl.getState().rows[0]).toEqual({ id: 30 })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('dispose stops further updates', async () => {
|
|
79
|
+
const src = makeSource(50, 20)
|
|
80
|
+
const onChange = vi.fn()
|
|
81
|
+
const ctl = createServerDataSource(src, { pageSize: 10, onChange })
|
|
82
|
+
ctl.refresh()
|
|
83
|
+
ctl.dispose()
|
|
84
|
+
await new Promise((r) => setTimeout(r, 40))
|
|
85
|
+
// only the synchronous loading=true emit happened before dispose
|
|
86
|
+
expect(ctl.getState().rows).toHaveLength(0)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Side Row Model (SSRM) controller. A single, documented datasource
|
|
3
|
+
* contract for grids whose data lives on the server - the "I have a million
|
|
4
|
+
* rows in a database" case. The consumer implements ONE async `getRows`
|
|
5
|
+
* function; this controller owns the request lifecycle (sort, filter, page),
|
|
6
|
+
* de-dupes/races, and pushes results back through `onChange`.
|
|
7
|
+
*
|
|
8
|
+
* It's headless and framework-agnostic on purpose: wire `setSort` /
|
|
9
|
+
* `setFilter` / `setPage` to the grid's controlled callbacks, and render the
|
|
10
|
+
* grid from the `{ rows, total, loading }` it hands you. See the demo.
|
|
11
|
+
*/
|
|
12
|
+
export type ServerSortModel = Array<{ id: string; desc: boolean }>
|
|
13
|
+
|
|
14
|
+
export type ServerFilterModel = {
|
|
15
|
+
/** Free-text global search. */
|
|
16
|
+
global?: string
|
|
17
|
+
/**
|
|
18
|
+
* Per-column filters, keyed by column id. `value` (+ `valueTo`) carry the
|
|
19
|
+
* operator-style filter; `selectedValues` carries a facet/checklist
|
|
20
|
+
* selection (set-filter). Either or both may be present.
|
|
21
|
+
*/
|
|
22
|
+
columns?: Record<
|
|
23
|
+
string,
|
|
24
|
+
{ operator: string; value: string; valueTo?: string; selectedValues?: string[] }
|
|
25
|
+
>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ServerRequest = {
|
|
29
|
+
/** Zero-based index of the first row wanted (inclusive). */
|
|
30
|
+
startRow: number
|
|
31
|
+
/** Index just past the last row wanted (exclusive). */
|
|
32
|
+
endRow: number
|
|
33
|
+
pageIndex: number
|
|
34
|
+
pageSize: number
|
|
35
|
+
sortModel: ServerSortModel
|
|
36
|
+
filterModel: ServerFilterModel
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ServerResult<TData> = {
|
|
40
|
+
rows: ReadonlyArray<TData>
|
|
41
|
+
/** Total row count after filtering (for the pager). */
|
|
42
|
+
rowCount: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ServerDataSource<TData> = {
|
|
46
|
+
getRows(request: ServerRequest): Promise<ServerResult<TData>>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ServerState<TData> = {
|
|
50
|
+
rows: ReadonlyArray<TData>
|
|
51
|
+
total: number
|
|
52
|
+
loading: boolean
|
|
53
|
+
error: unknown
|
|
54
|
+
pageIndex: number
|
|
55
|
+
pageSize: number
|
|
56
|
+
pageCount: number
|
|
57
|
+
sortModel: ServerSortModel
|
|
58
|
+
filterModel: ServerFilterModel
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ServerController<TData> = {
|
|
62
|
+
/** Re-fetch the current page (e.g. after a mutation). */
|
|
63
|
+
refresh(): void
|
|
64
|
+
setSort(sortModel: ServerSortModel): void
|
|
65
|
+
setFilter(filterModel: ServerFilterModel): void
|
|
66
|
+
setPage(pageIndex: number): void
|
|
67
|
+
setPageSize(pageSize: number): void
|
|
68
|
+
getState(): ServerState<TData>
|
|
69
|
+
/** Stop accepting in-flight responses (call on unmount). */
|
|
70
|
+
dispose(): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ServerControllerOptions<TData> = {
|
|
74
|
+
pageSize?: number
|
|
75
|
+
/** Called whenever any of `rows` / `total` / `loading` / page changes. */
|
|
76
|
+
onChange: (state: ServerState<TData>) => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createServerDataSource<TData>(
|
|
80
|
+
source: ServerDataSource<TData>,
|
|
81
|
+
options: ServerControllerOptions<TData>,
|
|
82
|
+
): ServerController<TData> {
|
|
83
|
+
const state: ServerState<TData> = {
|
|
84
|
+
rows: [],
|
|
85
|
+
total: 0,
|
|
86
|
+
loading: false,
|
|
87
|
+
error: null,
|
|
88
|
+
pageIndex: 0,
|
|
89
|
+
pageSize: options.pageSize ?? 50,
|
|
90
|
+
pageCount: 1,
|
|
91
|
+
sortModel: [],
|
|
92
|
+
filterModel: {},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Monotonic request id: only the latest fetch is allowed to land, so a slow
|
|
96
|
+
// response for an old sort/filter can't clobber a newer one.
|
|
97
|
+
let requestSeq = 0
|
|
98
|
+
let disposed = false
|
|
99
|
+
|
|
100
|
+
const emit = () => {
|
|
101
|
+
state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))
|
|
102
|
+
options.onChange({ ...state })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fetchPage() {
|
|
106
|
+
if (disposed) return
|
|
107
|
+
const id = ++requestSeq
|
|
108
|
+
state.loading = true
|
|
109
|
+
state.error = null
|
|
110
|
+
emit()
|
|
111
|
+
const startRow = state.pageIndex * state.pageSize
|
|
112
|
+
try {
|
|
113
|
+
const result = await source.getRows({
|
|
114
|
+
startRow,
|
|
115
|
+
endRow: startRow + state.pageSize,
|
|
116
|
+
pageIndex: state.pageIndex,
|
|
117
|
+
pageSize: state.pageSize,
|
|
118
|
+
sortModel: state.sortModel,
|
|
119
|
+
filterModel: state.filterModel,
|
|
120
|
+
})
|
|
121
|
+
if (disposed || id !== requestSeq) return // stale
|
|
122
|
+
state.rows = result.rows
|
|
123
|
+
state.total = result.rowCount
|
|
124
|
+
state.loading = false
|
|
125
|
+
emit()
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (disposed || id !== requestSeq) return
|
|
128
|
+
state.rows = []
|
|
129
|
+
state.error = err
|
|
130
|
+
state.loading = false
|
|
131
|
+
emit()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
refresh: fetchPage,
|
|
137
|
+
setSort(sortModel) {
|
|
138
|
+
state.sortModel = sortModel
|
|
139
|
+
state.pageIndex = 0
|
|
140
|
+
void fetchPage()
|
|
141
|
+
},
|
|
142
|
+
setFilter(filterModel) {
|
|
143
|
+
state.filterModel = filterModel
|
|
144
|
+
state.pageIndex = 0
|
|
145
|
+
void fetchPage()
|
|
146
|
+
},
|
|
147
|
+
setPage(pageIndex) {
|
|
148
|
+
const clamped = Math.max(0, pageIndex)
|
|
149
|
+
if (clamped === state.pageIndex) return
|
|
150
|
+
state.pageIndex = clamped
|
|
151
|
+
void fetchPage()
|
|
152
|
+
},
|
|
153
|
+
setPageSize(pageSize) {
|
|
154
|
+
state.pageSize = Math.max(1, pageSize)
|
|
155
|
+
state.pageIndex = 0
|
|
156
|
+
void fetchPage()
|
|
157
|
+
},
|
|
158
|
+
getState: () => ({ ...state }),
|
|
159
|
+
dispose() {
|
|
160
|
+
disposed = true
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildSparkline, toSparklineValues } from './sparkline'
|
|
3
|
+
|
|
4
|
+
describe('toSparklineValues', () => {
|
|
5
|
+
it('passes through number arrays and drops non-finite', () => {
|
|
6
|
+
expect(toSparklineValues([1, 2, NaN, 3])).toEqual([1, 2, 3])
|
|
7
|
+
})
|
|
8
|
+
it('parses comma / space separated strings', () => {
|
|
9
|
+
expect(toSparklineValues('1, 2 3,4')).toEqual([1, 2, 3, 4])
|
|
10
|
+
})
|
|
11
|
+
it('returns [] for empty / unusable input', () => {
|
|
12
|
+
expect(toSparklineValues(null)).toEqual([])
|
|
13
|
+
expect(toSparklineValues('')).toEqual([])
|
|
14
|
+
expect(toSparklineValues({})).toEqual([])
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('buildSparkline', () => {
|
|
19
|
+
it('returns null for empty data', () => {
|
|
20
|
+
expect(buildSparkline([])).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('builds a line path through every point', () => {
|
|
24
|
+
const g = buildSparkline([0, 5, 10], { type: 'line', width: 100, height: 20 })!
|
|
25
|
+
expect(g.type).toBe('line')
|
|
26
|
+
expect(g.linePath.startsWith('M')).toBe(true)
|
|
27
|
+
// three points => one M + two L commands
|
|
28
|
+
expect((g.linePath.match(/[ML]/g) ?? []).length).toBe(3)
|
|
29
|
+
expect(g.lastPoint).not.toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('closes the area path back to the baseline', () => {
|
|
33
|
+
const g = buildSparkline([1, 2, 3], { type: 'area' })!
|
|
34
|
+
expect(g.areaPath.endsWith('Z')).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('emits one bar per value and flags negatives', () => {
|
|
38
|
+
const g = buildSparkline([-2, 4, -1], { type: 'bar' })!
|
|
39
|
+
expect(g.bars).toHaveLength(3)
|
|
40
|
+
expect(g.bars[0]!.negative).toBe(true)
|
|
41
|
+
expect(g.bars[1]!.negative).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('winloss uses sign only, equal-height up/down bars', () => {
|
|
45
|
+
const g = buildSparkline([1, -1, 1], { type: 'winloss', height: 20 })!
|
|
46
|
+
expect(g.bars).toHaveLength(3)
|
|
47
|
+
expect(g.bars[0]!.negative).toBe(false)
|
|
48
|
+
expect(g.bars[1]!.negative).toBe(true)
|
|
49
|
+
// up and down bars are the same height (sign-only chart)
|
|
50
|
+
expect(g.bars[0]!.h).toBeCloseTo(g.bars[1]!.h)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('honors a fixed min/max scale', () => {
|
|
54
|
+
const a = buildSparkline([5, 5, 5], { type: 'line', min: 0, max: 10 })!
|
|
55
|
+
// flat series on a 0..10 scale sits at the vertical middle, not the top
|
|
56
|
+
const ys = a.linePath.match(/,(-?\d+(\.\d+)?)/g)!.map((s) => Number(s.slice(1)))
|
|
57
|
+
expect(new Set(ys).size).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
})
|