@svgrid/grid 3.0.4 → 3.0.6
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/README.md +32 -11
- package/dist/GridMenus.svelte +34 -4
- package/dist/SvGrid.controller.svelte.d.ts +33 -5
- package/dist/SvGrid.controller.svelte.js +397 -58
- package/dist/SvGrid.css +200 -2
- package/dist/SvGrid.svelte +350 -40
- package/dist/SvGrid.types.d.ts +433 -7
- package/dist/SvGridDropdown.svelte +20 -0
- package/dist/SvGridDropdown.svelte.d.ts +12 -0
- package/dist/SvModal.svelte +22 -0
- package/dist/SvModal.svelte.d.ts +7 -0
- package/dist/a11y/dismissable.d.ts +10 -0
- package/dist/a11y/dismissable.js +24 -2
- package/dist/build-api.js +92 -37
- package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-C-NtjlrJ.js} +155 -146
- package/dist/cdn/GridMenus-TssHej3q.js +644 -0
- package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
- package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
- package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
- package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
- package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
- package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
- package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
- package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
- package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
- package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
- package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
- package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
- package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
- package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
- package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
- package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
- package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
- package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
- package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
- package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
- package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
- package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
- package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
- package/dist/cdn/server-block-cache-DIqb3VZn.js +241 -0
- package/dist/cdn/{src-V1uu8iE9.js → src-CgoeSM8k.js} +5339 -4753
- package/dist/cdn/{src-BKhZ6eXd.js → src-DfwxB1TH.js} +9817 -9231
- package/dist/cdn/svgrid.js +28 -27
- package/dist/cdn/svgrid.svelte-external.js +28 -27
- package/dist/cdn/validate-AZoD8BoR.js +80 -0
- package/dist/cell-formatting.js +18 -1
- package/dist/cell-render.js +25 -6
- package/dist/clipboard.d.ts +2 -2
- package/dist/clipboard.js +72 -23
- package/dist/columns.js +7 -2
- package/dist/conditional-formatting.d.ts +2 -0
- package/dist/conditional-formatting.js +2 -0
- package/dist/core.d.ts +22 -6
- package/dist/core.js +401 -38
- package/dist/editing.js +34 -0
- package/dist/gantt-view.svelte.d.ts +24 -0
- package/dist/gantt-view.svelte.js +13 -0
- package/dist/grid-icons.d.ts +2 -2
- package/dist/grid-icons.js +2 -2
- package/dist/grid-messages.d.ts +10 -0
- package/dist/grid-messages.js +9 -0
- package/dist/headless.d.ts +5 -0
- package/dist/headless.js +12 -0
- package/dist/history.d.ts +21 -0
- package/dist/history.js +34 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/keyboard-handlers.js +24 -1
- package/dist/keyboard.js +4 -0
- package/dist/row-drag-touch.d.ts +1 -1
- package/dist/row-drag.js +35 -1
- package/dist/row-model.d.ts +139 -0
- package/dist/row-model.js +16 -0
- package/dist/row-resize.js +22 -1
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +31 -3
- package/dist/server-block-cache.d.ts +219 -0
- package/dist/server-block-cache.js +539 -0
- package/dist/server-data-source.d.ts +274 -8
- package/dist/server-data-source.js +229 -20
- package/dist/server.d.ts +13 -0
- package/dist/server.js +13 -0
- package/dist/sparkline.js +7 -5
- package/dist/svgrid-wrapper.types.d.ts +33 -0
- package/dist/validate.d.ts +4 -0
- package/dist/validate.js +29 -3
- package/package.json +11 -1
- package/src/GridMenus.svelte +34 -4
- package/src/SvGrid.controller.svelte.ts +397 -61
- package/src/SvGrid.css +200 -2
- package/src/SvGrid.svelte +350 -40
- package/src/SvGrid.types.ts +453 -7
- package/src/SvGridDropdown.svelte +20 -0
- package/src/SvModal.svelte +22 -0
- package/src/SvModal.test.ts +30 -1
- package/src/a11y/dismissable.test.ts +10 -1
- package/src/a11y/dismissable.ts +22 -2
- package/src/build-api.ts +96 -31
- package/src/cell-formatting.test.ts +17 -0
- package/src/cell-formatting.ts +18 -1
- package/src/cell-render.test.ts +24 -2
- package/src/cell-render.ts +24 -8
- package/src/clipboard.test.ts +107 -0
- package/src/clipboard.ts +62 -23
- package/src/columns.test.ts +4 -4
- package/src/columns.ts +7 -2
- package/src/conditional-formatting.test.ts +8 -0
- package/src/conditional-formatting.ts +3 -0
- package/src/core.rowmodel-cache.test.ts +51 -0
- package/src/core.tick-repair.test.ts +287 -0
- package/src/core.ts +407 -38
- package/src/editing.test.ts +52 -0
- package/src/editing.ts +33 -0
- package/src/gantt-stub.test.svelte +38 -0
- package/src/gantt-view.svelte.ts +35 -0
- package/src/grid-icons.ts +2 -2
- package/src/grid-messages.test.ts +7 -0
- package/src/grid-messages.ts +22 -0
- package/src/headless.ts +24 -0
- package/src/history.test.ts +49 -1
- package/src/history.ts +48 -2
- package/src/icon-seam.test.ts +2 -4
- package/src/index.ts +38 -10
- package/src/keyboard-handlers.coverage.test.ts +65 -0
- package/src/keyboard-handlers.ts +26 -1
- package/src/keyboard.ts +3 -0
- package/src/row-drag-touch.ts +2 -2
- package/src/row-drag.test.ts +51 -0
- package/src/row-drag.ts +35 -2
- package/src/row-model.ts +152 -0
- package/src/row-resize.ts +24 -1
- package/src/selection.test.ts +3 -0
- package/src/selection.ts +33 -3
- package/src/server-block-cache.test.ts +672 -0
- package/src/server-block-cache.ts +690 -0
- package/src/server-data-source.infinite.test.ts +405 -0
- package/src/server-data-source.test.ts +19 -0
- package/src/server-data-source.ts +496 -29
- package/src/server.ts +49 -0
- package/src/sparkline.test.ts +7 -0
- package/src/sparkline.ts +6 -4
- package/src/svgrid-wrapper.types.ts +32 -0
- package/src/svgrid.api.test.ts +21 -0
- package/src/svgrid.charting.test.ts +12 -4
- package/src/svgrid.context-menu.test.ts +12 -0
- package/src/svgrid.detail-rows.svelte.test.ts +187 -0
- package/src/svgrid.filter-menu-scroll.test.ts +9 -0
- package/src/svgrid.gantt-seam.test.ts +199 -0
- package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
- package/src/svgrid.menu-scroll-close.test.ts +10 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
- package/src/svgrid.row-model-seam.svelte.test.ts +305 -0
- package/src/svgrid.row-pinning.test.ts +15 -1
- package/src/svgrid.selection-bar-seam.test.ts +1 -1
- package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
- package/src/svgrid.upsell-license.test.ts +8 -5
- package/src/test-setup.ts +12 -0
- package/src/transaction.test.ts +58 -0
- package/src/validate.test.ts +29 -0
- package/src/validate.ts +34 -3
- package/dist/SvGroupCell.svelte +0 -141
- package/dist/SvGroupCell.svelte.d.ts +0 -49
- package/dist/SvRowGroupPanel.svelte +0 -186
- package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
- package/dist/cdn/GridMenus-7kbpnnBW.js +0 -635
- package/dist/cdn/validate-_CDJzgIo.js +0 -75
- package/dist/server-group-model.d.ts +0 -98
- package/dist/server-group-model.js +0 -263
- package/src/SvGroupCell.svelte +0 -141
- package/src/SvRowGroupPanel.svelte +0 -186
- package/src/server-group-model.test.ts +0 -294
- package/src/server-group-model.ts +0 -370
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two ways a live feed can change a row, and what each one reaches.
|
|
3
|
+
*
|
|
4
|
+
* A `$state` array passed as `data` has two update paths and they are not
|
|
5
|
+
* interchangeable, which is the whole design problem of a ticking grid:
|
|
6
|
+
*
|
|
7
|
+
* 1. Mutate a field through the proxy (`rows[i].price = x`). The visible
|
|
8
|
+
* cell re-renders because its template reads the field through the
|
|
9
|
+
* proxy. The row model does NOT run - it caches on the array reference
|
|
10
|
+
* and the reference did not change - so an active sort or filter is
|
|
11
|
+
* stale for that row until something replaces the array.
|
|
12
|
+
* 2. Replace the array (`rows = next`, or `api.applyTransaction`). The row
|
|
13
|
+
* model rebuilds and re-runs the pipeline over every row; sort and filter
|
|
14
|
+
* are current again.
|
|
15
|
+
*
|
|
16
|
+
* Replacing one ELEMENT through the proxy (`rows[i] = {...}`) is the case a
|
|
17
|
+
* React-trained hand reaches for, and it is the one that does neither: the
|
|
18
|
+
* base row keeps its old `original`, so the cell keeps its old value.
|
|
19
|
+
*
|
|
20
|
+
* These are the facts the real-time docs and the blog describe; if one of
|
|
21
|
+
* them changes, this file is where it shows up first.
|
|
22
|
+
*
|
|
23
|
+
* Lives in a `.svelte.test.ts` so `$state` is available.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, expect, it } from 'vitest'
|
|
26
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
27
|
+
import SvGrid from './SvGrid.svelte'
|
|
28
|
+
import {
|
|
29
|
+
createCoreRowModel,
|
|
30
|
+
createFilteredRowModel,
|
|
31
|
+
createSortedRowModel,
|
|
32
|
+
rowSortingFeature,
|
|
33
|
+
sortFns,
|
|
34
|
+
tableFeatures,
|
|
35
|
+
} from './index'
|
|
36
|
+
import type { SvGridApi } from './index'
|
|
37
|
+
|
|
38
|
+
type Quote = { id: string; symbol: string; price: number }
|
|
39
|
+
|
|
40
|
+
const features = tableFeatures({ rowSortingFeature })
|
|
41
|
+
const columns = [
|
|
42
|
+
{ field: 'symbol', header: 'Symbol', width: 120 },
|
|
43
|
+
{ field: 'price', header: 'Price', width: 120, editorType: 'number' as const },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
function seed(): Quote[] {
|
|
47
|
+
return [
|
|
48
|
+
{ id: 'a', symbol: 'AAA', price: 10 },
|
|
49
|
+
{ id: 'b', symbol: 'BBB', price: 20 },
|
|
50
|
+
{ id: 'c', symbol: 'CCC', price: 30 },
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The props object must be the `$state` proxy itself, not a copy of it: a
|
|
56
|
+
* spread would snapshot `data` at mount and the reassignment tests would be
|
|
57
|
+
* testing nothing.
|
|
58
|
+
*/
|
|
59
|
+
function liveProps(data: Quote[]) {
|
|
60
|
+
const props = $state({
|
|
61
|
+
data,
|
|
62
|
+
columns,
|
|
63
|
+
features,
|
|
64
|
+
_rowModels: {
|
|
65
|
+
coreRowModel: createCoreRowModel(),
|
|
66
|
+
filteredRowModel: createFilteredRowModel(),
|
|
67
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
68
|
+
},
|
|
69
|
+
getRowId: (r: Quote) => r.id,
|
|
70
|
+
// Price descending: CCC, BBB, AAA. A tick that lifts AAA above CCC is
|
|
71
|
+
// the observable difference between "cell updated" and "re-sorted".
|
|
72
|
+
initialSorting: [{ id: 'price', desc: true }],
|
|
73
|
+
containerHeight: 400,
|
|
74
|
+
virtualization: false,
|
|
75
|
+
onApiReady: undefined as ((next: SvGridApi<typeof features, Quote>) => void) | undefined,
|
|
76
|
+
})
|
|
77
|
+
return props
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mountGrid(props: ReturnType<typeof liveProps>) {
|
|
81
|
+
const target = document.createElement('div')
|
|
82
|
+
document.body.appendChild(target)
|
|
83
|
+
let api: SvGridApi<typeof features, Quote> | null = null
|
|
84
|
+
props.onApiReady = (next) => (api = next)
|
|
85
|
+
const app = mount(SvGrid, { target, props: props as never })
|
|
86
|
+
flushSync()
|
|
87
|
+
const dataRows = () =>
|
|
88
|
+
Array.from(target.querySelectorAll('tbody.sv-grid-body tr.sv-grid-row:not(.sv-grid-row-spacer)'))
|
|
89
|
+
const cellsOf = (tr: Element) =>
|
|
90
|
+
Array.from(tr.querySelectorAll('.sv-grid-cell:not(.sv-grid-selection-cell)')).map((td) =>
|
|
91
|
+
td.textContent?.trim(),
|
|
92
|
+
)
|
|
93
|
+
const symbolsInOrder = () => dataRows().map((tr) => cellsOf(tr)[0])
|
|
94
|
+
const priceOf = (symbol: string) => {
|
|
95
|
+
const tr = dataRows().find((row) => cellsOf(row)[0] === symbol)
|
|
96
|
+
return tr ? cellsOf(tr)[1] : undefined
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
app,
|
|
100
|
+
target,
|
|
101
|
+
api: () => api!,
|
|
102
|
+
symbolsInOrder,
|
|
103
|
+
priceOf,
|
|
104
|
+
done() {
|
|
105
|
+
unmount(app)
|
|
106
|
+
target.remove()
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('live update paths', () => {
|
|
112
|
+
it('starts sorted by price descending', () => {
|
|
113
|
+
const props = liveProps(seed())
|
|
114
|
+
const g = mountGrid(props)
|
|
115
|
+
expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
|
|
116
|
+
g.done()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('proxy field write: the cell updates, the sort does not re-run', () => {
|
|
120
|
+
const props = liveProps(seed())
|
|
121
|
+
const g = mountGrid(props)
|
|
122
|
+
|
|
123
|
+
props.data[0]!.price = 99 // AAA now the highest price
|
|
124
|
+
flushSync()
|
|
125
|
+
|
|
126
|
+
expect(g.priceOf('AAA')).toBe('99')
|
|
127
|
+
// Still where it was: the row model never saw the write.
|
|
128
|
+
expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
|
|
129
|
+
g.done()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('proxy element replacement: neither the cell nor the sort updates', () => {
|
|
133
|
+
const props = liveProps(seed())
|
|
134
|
+
const g = mountGrid(props)
|
|
135
|
+
|
|
136
|
+
props.data[0] = { ...props.data[0]!, price: 99 }
|
|
137
|
+
flushSync()
|
|
138
|
+
|
|
139
|
+
// The base row still points at the object it was built from.
|
|
140
|
+
expect(g.priceOf('AAA')).toBe('10')
|
|
141
|
+
expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
|
|
142
|
+
g.done()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('array reassignment: the cell updates and the sort re-runs', () => {
|
|
146
|
+
const props = liveProps(seed())
|
|
147
|
+
const g = mountGrid(props)
|
|
148
|
+
|
|
149
|
+
props.data = props.data.map((r) => (r.id === 'a' ? { ...r, price: 99 } : r))
|
|
150
|
+
flushSync()
|
|
151
|
+
|
|
152
|
+
expect(g.priceOf('AAA')).toBe('99')
|
|
153
|
+
expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
|
|
154
|
+
g.done()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('applyTransaction: the cell updates and the sort re-runs', () => {
|
|
158
|
+
const props = liveProps(seed())
|
|
159
|
+
const g = mountGrid(props)
|
|
160
|
+
|
|
161
|
+
g.api().applyTransaction({ update: [{ id: 'a', symbol: 'AAA', price: 99 }] })
|
|
162
|
+
flushSync()
|
|
163
|
+
|
|
164
|
+
expect(g.priceOf('AAA')).toBe('99')
|
|
165
|
+
expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
|
|
166
|
+
g.done()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('a proxy field write is caught up by the next array replacement', () => {
|
|
170
|
+
const props = liveProps(seed())
|
|
171
|
+
const g = mountGrid(props)
|
|
172
|
+
|
|
173
|
+
props.data[0]!.price = 99
|
|
174
|
+
flushSync()
|
|
175
|
+
expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
|
|
176
|
+
|
|
177
|
+
// Any structural change re-runs the pipeline over the current values,
|
|
178
|
+
// including the one the proxy write left behind.
|
|
179
|
+
props.data = [...props.data]
|
|
180
|
+
flushSync()
|
|
181
|
+
expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
|
|
182
|
+
g.done()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -75,6 +75,14 @@ function mountGrid(extra: Record<string, unknown> = {}) {
|
|
|
75
75
|
)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The close-on-scroll listener arms one animation frame after a popover
|
|
80
|
+
* mounts, so a scroll queued before the opening click cannot dismiss what
|
|
81
|
+
* that click opened. Every open helper waits that frame out before a case
|
|
82
|
+
* dispatches its scroll.
|
|
83
|
+
*/
|
|
84
|
+
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
85
|
+
|
|
78
86
|
async function openContextMenu(target: HTMLElement) {
|
|
79
87
|
const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]') as HTMLElement
|
|
80
88
|
const r = cell.getBoundingClientRect()
|
|
@@ -86,6 +94,7 @@ async function openContextMenu(target: HTMLElement) {
|
|
|
86
94
|
expect(menu).not.toBeNull()
|
|
87
95
|
expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
|
|
88
96
|
})
|
|
97
|
+
await nextFrame()
|
|
89
98
|
return target.querySelector('.sv-grid-context-menu') as HTMLElement
|
|
90
99
|
}
|
|
91
100
|
|
|
@@ -100,6 +109,7 @@ async function openCommentEditor(target: HTMLElement) {
|
|
|
100
109
|
await vi.waitFor(() => {
|
|
101
110
|
expect(target.querySelector('.sv-grid-comment-editor')).not.toBeNull()
|
|
102
111
|
})
|
|
112
|
+
await nextFrame()
|
|
103
113
|
return target.querySelector('.sv-grid-comment-editor') as HTMLElement
|
|
104
114
|
}
|
|
105
115
|
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<SvGrid rowModel={ctl} />` - the one-prop integration.
|
|
3
|
+
*
|
|
4
|
+
* The seams below it are tested in `svgrid.row-model-seam.svelte.test.ts`;
|
|
5
|
+
* this suite is about what the prop itself is for: that handing the grid a
|
|
6
|
+
* model wires all of those seams at once, that the model drives re-renders,
|
|
7
|
+
* and that writing a prop explicitly still wins - so adopting it is never
|
|
8
|
+
* all-or-nothing.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { mount, unmount } from 'svelte'
|
|
12
|
+
import SvGrid from './SvGrid.svelte'
|
|
13
|
+
import {
|
|
14
|
+
createCoreRowModel,
|
|
15
|
+
createServerDataSource,
|
|
16
|
+
rowSelectionFeature,
|
|
17
|
+
rowSortingFeature,
|
|
18
|
+
tableFeatures,
|
|
19
|
+
type ColumnDef,
|
|
20
|
+
type GridRowModel,
|
|
21
|
+
type ServerDataSource,
|
|
22
|
+
type SvGridApi,
|
|
23
|
+
} from './index'
|
|
24
|
+
|
|
25
|
+
type Row = { id: number; name: string }
|
|
26
|
+
const features = tableFeatures({ rowSortingFeature, rowSelectionFeature })
|
|
27
|
+
const cols: ColumnDef<typeof features, Row>[] = [{ field: 'name', header: 'Name', width: 160 }]
|
|
28
|
+
|
|
29
|
+
const tick = () => new Promise<void>((r) => setTimeout(r))
|
|
30
|
+
const settle = async () => {
|
|
31
|
+
for (let i = 0; i < 20; i += 1) await Promise.resolve()
|
|
32
|
+
await tick()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mountGrid(props: Record<string, unknown>) {
|
|
36
|
+
return new Promise<{
|
|
37
|
+
api: SvGridApi<typeof features, Row>
|
|
38
|
+
target: HTMLElement
|
|
39
|
+
destroy: () => void
|
|
40
|
+
}>((res, rej) => {
|
|
41
|
+
const target = document.createElement('div')
|
|
42
|
+
document.body.appendChild(target)
|
|
43
|
+
const app = mount(SvGrid, {
|
|
44
|
+
target,
|
|
45
|
+
props: {
|
|
46
|
+
columns: cols,
|
|
47
|
+
features,
|
|
48
|
+
_rowModels: { coreRowModel: createCoreRowModel() },
|
|
49
|
+
containerHeight: 200,
|
|
50
|
+
virtualization: false,
|
|
51
|
+
onApiReady(api: SvGridApi<typeof features, Row>) {
|
|
52
|
+
res({ api, target, destroy: () => { unmount(app); target.remove() } })
|
|
53
|
+
},
|
|
54
|
+
...props,
|
|
55
|
+
} as any,
|
|
56
|
+
})
|
|
57
|
+
queueMicrotask(() => {
|
|
58
|
+
if (!target.querySelector('[role="grid"]')) rej(new Error('no grid'))
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A hand-written model, to prove the grid depends on the shape not the class. */
|
|
64
|
+
function handWritten(rows: Row[]) {
|
|
65
|
+
const listeners = new Set<() => void>()
|
|
66
|
+
let current = rows
|
|
67
|
+
let loading = false
|
|
68
|
+
const sorts: Array<unknown> = []
|
|
69
|
+
return {
|
|
70
|
+
sorts,
|
|
71
|
+
push(next: Row[]) {
|
|
72
|
+
current = next
|
|
73
|
+
for (const l of listeners) l()
|
|
74
|
+
},
|
|
75
|
+
setLoading(next: boolean) {
|
|
76
|
+
loading = next
|
|
77
|
+
for (const l of listeners) l()
|
|
78
|
+
},
|
|
79
|
+
model: {
|
|
80
|
+
subscribe(onChange: () => void) {
|
|
81
|
+
listeners.add(onChange)
|
|
82
|
+
return () => listeners.delete(onChange)
|
|
83
|
+
},
|
|
84
|
+
getRows: () => current,
|
|
85
|
+
isLoading: () => loading,
|
|
86
|
+
getRowId: (r: Row) => String(r.id),
|
|
87
|
+
setSort: (s: unknown) => sorts.push(s),
|
|
88
|
+
} satisfies GridRowModel<Row>,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('the rowModel prop', () => {
|
|
93
|
+
it('renders the rows the model reports, with no data prop at all', async () => {
|
|
94
|
+
const source = handWritten([
|
|
95
|
+
{ id: 1, name: 'Ada' },
|
|
96
|
+
{ id: 2, name: 'Grace' },
|
|
97
|
+
])
|
|
98
|
+
const { target, destroy } = await mountGrid({ rowModel: source.model })
|
|
99
|
+
await tick()
|
|
100
|
+
|
|
101
|
+
expect(target.textContent).toContain('Ada')
|
|
102
|
+
expect(target.textContent).toContain('Grace')
|
|
103
|
+
destroy()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('re-renders when the model says it changed', async () => {
|
|
107
|
+
const source = handWritten([{ id: 1, name: 'Ada' }])
|
|
108
|
+
const { target, destroy } = await mountGrid({ rowModel: source.model })
|
|
109
|
+
await tick()
|
|
110
|
+
expect(target.textContent).not.toContain('Linus')
|
|
111
|
+
|
|
112
|
+
source.push([{ id: 1, name: 'Ada' }, { id: 9, name: 'Linus' }])
|
|
113
|
+
await vi.waitFor(() => expect(target.textContent).toContain('Linus'))
|
|
114
|
+
destroy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('unsubscribes on unmount', async () => {
|
|
118
|
+
const source = handWritten([{ id: 1, name: 'Ada' }])
|
|
119
|
+
const { destroy } = await mountGrid({ rowModel: source.model })
|
|
120
|
+
await tick()
|
|
121
|
+
destroy()
|
|
122
|
+
await tick()
|
|
123
|
+
|
|
124
|
+
// A model that outlives the grid must not keep it alive through a
|
|
125
|
+
// listener; pushing after unmount should reach nobody and throw nothing.
|
|
126
|
+
expect(() => source.push([{ id: 2, name: 'Grace' }])).not.toThrow()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('takes over sorting when the model sorts, and says so to the grid', async () => {
|
|
130
|
+
const source = handWritten([
|
|
131
|
+
{ id: 1, name: 'Zoe' },
|
|
132
|
+
{ id: 2, name: 'Ada' },
|
|
133
|
+
])
|
|
134
|
+
const { api, target, destroy } = await mountGrid({ rowModel: source.model, sortable: true })
|
|
135
|
+
await tick()
|
|
136
|
+
|
|
137
|
+
api.setSort('name', 'asc')
|
|
138
|
+
await tick()
|
|
139
|
+
|
|
140
|
+
// The model was told; the grid did NOT reorder locally, because a model
|
|
141
|
+
// with `setSort` implies `externalSort`. (The grid also reports the
|
|
142
|
+
// empty initial sort on mount, as it does for any onSortingChange.)
|
|
143
|
+
expect(source.sorts.at(-1)).toEqual([{ id: 'name', desc: false }])
|
|
144
|
+
const firstRow = target.querySelector('tbody .sv-grid-row')!
|
|
145
|
+
expect(firstRow.textContent).toContain('Zoe')
|
|
146
|
+
destroy()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('lets an explicit prop win over the model', async () => {
|
|
150
|
+
const source = handWritten([{ id: 1, name: 'Ada' }])
|
|
151
|
+
const { target, destroy } = await mountGrid({
|
|
152
|
+
rowModel: source.model,
|
|
153
|
+
data: [{ id: 7, name: 'Override' }],
|
|
154
|
+
})
|
|
155
|
+
await tick()
|
|
156
|
+
|
|
157
|
+
expect(target.textContent).toContain('Override')
|
|
158
|
+
expect(target.textContent).not.toContain('Ada')
|
|
159
|
+
destroy()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('drives a real server controller end to end', async () => {
|
|
163
|
+
const source: ServerDataSource<Row> = {
|
|
164
|
+
async getRows(req) {
|
|
165
|
+
const all: Row[] = Array.from({ length: 250 }, (_, i) => ({ id: i, name: `Row ${i}` }))
|
|
166
|
+
const sorted = req.sortModel[0]?.desc ? [...all].reverse() : all
|
|
167
|
+
return { rows: sorted.slice(req.startRow, req.endRow), rowCount: all.length }
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
const ctl = createServerDataSource<Row>(source, {
|
|
171
|
+
mode: 'infinite',
|
|
172
|
+
blockSize: 100,
|
|
173
|
+
onChange: () => {},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const { target, destroy } = await mountGrid({ rowModel: ctl })
|
|
177
|
+
|
|
178
|
+
// The grid reported its visible range, the controller fetched block 0, and
|
|
179
|
+
// the rows arrived - with no wiring in this test beyond the one prop.
|
|
180
|
+
await vi.waitFor(() => expect(target.textContent).toContain('Row 0'))
|
|
181
|
+
expect(ctl.getCacheState().length).toBeGreaterThan(0)
|
|
182
|
+
expect(ctl.getRows()).toHaveLength(250)
|
|
183
|
+
destroy()
|
|
184
|
+
ctl.dispose()
|
|
185
|
+
}, 20_000)
|
|
186
|
+
|
|
187
|
+
it('wires the pager from a page-mode controller', async () => {
|
|
188
|
+
const source: ServerDataSource<Row> = {
|
|
189
|
+
async getRows(req) {
|
|
190
|
+
const all: Row[] = Array.from({ length: 95 }, (_, i) => ({ id: i, name: `Row ${i}` }))
|
|
191
|
+
return { rows: all.slice(req.startRow, req.endRow), rowCount: all.length }
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
const ctl = createServerDataSource<Row>(source, { pageSize: 20, onChange: () => {} })
|
|
195
|
+
ctl.refresh()
|
|
196
|
+
await settle()
|
|
197
|
+
|
|
198
|
+
const { api, destroy } = await mountGrid({ rowModel: ctl, pageable: true })
|
|
199
|
+
|
|
200
|
+
// 95 rows on the server, not the 20 in hand: the count came from the model.
|
|
201
|
+
await vi.waitFor(() => expect(api.getPageInfo().pageCount).toBe(5))
|
|
202
|
+
expect(api.getPageInfo().pageSize).toBe(20)
|
|
203
|
+
destroy()
|
|
204
|
+
ctl.dispose()
|
|
205
|
+
}, 20_000)
|
|
206
|
+
|
|
207
|
+
it('takes the page-size choices from the model and sizes the page to the body on request', async () => {
|
|
208
|
+
const calls: number[] = []
|
|
209
|
+
let pageSize = 10
|
|
210
|
+
const rows: Row[] = Array.from({ length: 50 }, (_, i) => ({ id: i, name: `Row ${i}` }))
|
|
211
|
+
const model: GridRowModel<Row> = {
|
|
212
|
+
subscribe: () => () => {},
|
|
213
|
+
getRows: () => rows.slice(0, pageSize),
|
|
214
|
+
isLoading: () => false,
|
|
215
|
+
get pagination() {
|
|
216
|
+
return {
|
|
217
|
+
pageIndex: 0,
|
|
218
|
+
pageSize,
|
|
219
|
+
rowCount: 50,
|
|
220
|
+
setPage: () => {},
|
|
221
|
+
setPageSize: (n: number) => {
|
|
222
|
+
calls.push(n)
|
|
223
|
+
pageSize = n
|
|
224
|
+
},
|
|
225
|
+
pageSizes: [7, 14, 21],
|
|
226
|
+
autoPageSize: true,
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
// jsdom lays nothing out; give every element a 100px box so the grid
|
|
231
|
+
// measures its body on mount (the way the responsive test fakes width).
|
|
232
|
+
const desc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
|
233
|
+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 100 })
|
|
234
|
+
try {
|
|
235
|
+
const { api, target, destroy } = await mountGrid({ rowModel: model, pageable: true, rowHeight: 20 })
|
|
236
|
+
// The footer offers the model's sizes, not the grid's defaults.
|
|
237
|
+
await vi.waitFor(() => expect(target.querySelector('.sv-grid-pagination')).toBeTruthy())
|
|
238
|
+
expect(api.getOption('pageSizeOptions')).toEqual([7, 14, 21])
|
|
239
|
+
// Auto size: 100px of body over 20px rows is a page of five.
|
|
240
|
+
await vi.waitFor(() => expect(calls.at(-1)).toBe(5))
|
|
241
|
+
destroy()
|
|
242
|
+
} finally {
|
|
243
|
+
if (desc) Object.defineProperty(HTMLElement.prototype, 'clientHeight', desc)
|
|
244
|
+
else delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight
|
|
245
|
+
}
|
|
246
|
+
}, 20_000)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('editing under a row model', () => {
|
|
250
|
+
type Node = Row & { kind: "group" | "leaf" }
|
|
251
|
+
it('refuses to edit a server group row or a placeholder, and edits a leaf', async () => {
|
|
252
|
+
const nodes: Node[] = [
|
|
253
|
+
{ id: 1, name: 'EMEA', kind: 'group' },
|
|
254
|
+
{ id: 2, name: 'Ada', kind: 'leaf' },
|
|
255
|
+
]
|
|
256
|
+
const model: GridRowModel<Node> = {
|
|
257
|
+
subscribe: () => () => {},
|
|
258
|
+
getRows: () => nodes,
|
|
259
|
+
isLoading: () => false,
|
|
260
|
+
getRowId: (r) => String(r.id),
|
|
261
|
+
group: {
|
|
262
|
+
isGroup: (r) => r.kind === 'group',
|
|
263
|
+
level: () => 0,
|
|
264
|
+
expanded: () => false,
|
|
265
|
+
onToggle: () => {},
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
const { api, target, destroy } = await mountGrid({
|
|
269
|
+
rowModel: model,
|
|
270
|
+
columns: [{ field: 'name', header: 'Name', width: 160, editorType: 'text' }],
|
|
271
|
+
editable: true,
|
|
272
|
+
})
|
|
273
|
+
try {
|
|
274
|
+
// The group row: nothing opens, whatever the column says.
|
|
275
|
+
api.startEditing(0, 'name')
|
|
276
|
+
await tick()
|
|
277
|
+
expect(target.querySelector('.sv-grid-cell input')).toBeNull()
|
|
278
|
+
// The leaf: an editor.
|
|
279
|
+
api.startEditing(1, 'name')
|
|
280
|
+
await vi.waitFor(() => expect(target.querySelector('.sv-grid-cell input')).not.toBeNull())
|
|
281
|
+
} finally {
|
|
282
|
+
destroy()
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('the rowModel prop with virtualization on', () => {
|
|
288
|
+
/** 100k rows, so only a window is rendered and most rows are placeholders. */
|
|
289
|
+
function bigSource(): ServerDataSource<Row> {
|
|
290
|
+
return {
|
|
291
|
+
async getRows(req) {
|
|
292
|
+
const rows: Row[] = []
|
|
293
|
+
for (let i = req.startRow; i < Math.min(req.endRow, 100_000); i += 1) {
|
|
294
|
+
rows.push({ id: i, name: `Row ${i}` })
|
|
295
|
+
}
|
|
296
|
+
return { rows, rowCount: 100_000 }
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it('renders skeletons for the rows it has not loaded', async () => {
|
|
302
|
+
const ctl = createServerDataSource<Row>(bigSource(), {
|
|
303
|
+
mode: 'infinite',
|
|
304
|
+
blockSize: 100,
|
|
305
|
+
onChange: () => {},
|
|
306
|
+
})
|
|
307
|
+
const { target, destroy } = await mountGrid({
|
|
308
|
+
rowModel: ctl,
|
|
309
|
+
virtualization: true,
|
|
310
|
+
containerHeight: 300,
|
|
311
|
+
rowHeight: 30,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// Block 0 landed, so the window at the top is real data.
|
|
315
|
+
await vi.waitFor(() => expect(target.textContent).toContain('Row 0'))
|
|
316
|
+
|
|
317
|
+
// The grid claims the whole table, and everything outside the loaded
|
|
318
|
+
// block is a placeholder - which is what keeps the scrollbar honest.
|
|
319
|
+
const rows = ctl.getRows()
|
|
320
|
+
expect(rows).toHaveLength(100_000)
|
|
321
|
+
expect(ctl.rowPlaceholder?.(rows[50_000] as Row, 50_000)).toBe('loading')
|
|
322
|
+
destroy()
|
|
323
|
+
ctl.dispose()
|
|
324
|
+
}, 20_000)
|
|
325
|
+
|
|
326
|
+
it('draws skeleton cells for a row the model calls a placeholder', async () => {
|
|
327
|
+
// Straight through the seam, with no fetching involved: the question here
|
|
328
|
+
// is only whether `rowModel.rowPlaceholder` reaches the renderer. Driving
|
|
329
|
+
// it from a real controller cannot answer that - with virtualization off
|
|
330
|
+
// the grid reports every row as visible, so every block loads and no
|
|
331
|
+
// placeholder survives to be rendered.
|
|
332
|
+
const rows: Row[] = [
|
|
333
|
+
{ id: 0, name: 'Ada' },
|
|
334
|
+
{ id: 1, name: 'Grace' },
|
|
335
|
+
{ id: 2, name: 'Linus' },
|
|
336
|
+
]
|
|
337
|
+
const model: GridRowModel<Row> = {
|
|
338
|
+
subscribe: () => () => {},
|
|
339
|
+
getRows: () => rows,
|
|
340
|
+
isLoading: () => false,
|
|
341
|
+
rowPlaceholder: (row) => (row.id === 2 ? 'loading' : null),
|
|
342
|
+
}
|
|
343
|
+
const { target, destroy } = await mountGrid({ rowModel: model })
|
|
344
|
+
await tick()
|
|
345
|
+
|
|
346
|
+
const placeholder = target.querySelector('.sv-grid-placeholder-row')!
|
|
347
|
+
expect(placeholder).not.toBeNull()
|
|
348
|
+
expect(placeholder.querySelectorAll('.sv-grid-placeholder-skeleton').length).toBe(cols.length)
|
|
349
|
+
expect(target.textContent).toContain('Ada')
|
|
350
|
+
destroy()
|
|
351
|
+
})
|
|
352
|
+
})
|