@svgrid/grid 2.6.23 → 3.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/CHANGELOG.md +88 -88
- package/README.md +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +181 -181
- package/dist/SvAutoComplete.svelte +169 -169
- package/dist/SvAvatar.svelte +75 -75
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +102 -102
- package/dist/SvCircularProgress.svelte +109 -109
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +279 -279
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +163 -163
- package/dist/SvDrawer.svelte +254 -254
- package/dist/SvDropDownList.svelte +378 -378
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGridCellEditor.svelte +744 -744
- package/dist/SvGridChart.svelte +1716 -1716
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +728 -728
- package/dist/SvGridSelect.svelte +270 -270
- package/dist/SvGroupCell.svelte +116 -116
- package/dist/SvListBox.svelte +334 -334
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +293 -293
- package/dist/SvNumberInput.svelte +172 -172
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +133 -133
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvProgress.svelte +116 -116
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRating.svelte +112 -112
- package/dist/SvResult.svelte +73 -73
- package/dist/SvRichText.svelte +211 -211
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +203 -203
- package/dist/SvSwitchButton.svelte +108 -108
- package/dist/SvTagsInput.svelte +115 -115
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +208 -208
- package/dist/SvTree.svelte +444 -444
- package/dist/SvTreeSelect.svelte +239 -239
- package/dist/cdn/{GridMenus-BzWXQjv3.js → GridMenus-DMihUtma.js} +3 -3
- package/dist/cdn/{GridMenus-xr_rHx8F.js → GridMenus-nmNDj1a3.js} +3 -3
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-Bh0A0JkF.js} +1 -1
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-DflbiP7N.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-BWwpfB_o.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-CCbDZNZB.js → SvDateTimePicker-Bivn8dAP.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-BAPSC7EL.js → SvGridCellEditor-AXL8cHGO.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-BwzmUWtL.js → SvGridCellEditor-Ba7rY3eu.js} +1 -1
- package/dist/cdn/{SvGridChart-BwVos976.js → SvGridChart-Bs2GIR2Q.js} +1 -1
- package/dist/cdn/{SvGridChart-BEJmNNx9.js → SvGridChart-CwhFz7GV.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-BOknOkrP.js → SvGridChartPanel-1WXuStzM.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-D2PjII4O.js → SvGridChartPanel-e5_nqNP9.js} +1 -1
- package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-SmPW10dI.js} +1 -1
- package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-eSPuJE6g.js} +1 -1
- package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-B1ZcLpgs.js} +1 -1
- package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CtdbHcIS.js} +1 -1
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CcP1tafP.js} +2 -2
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-DZT7T1wf.js} +2 -2
- package/dist/cdn/{row-drag-touch-Ddaa-QeB.js → row-drag-touch-CycRR65n.js} +12 -10
- package/dist/cdn/{src-skQN5eqh.js → src-B_YS5AOc.js} +45 -45
- package/dist/cdn/{src-DGo7IHug.js → src-DYXXpuSk.js} +44 -44
- package/dist/cdn/svgrid.js +7 -7
- package/dist/cdn/svgrid.svelte-external.js +7 -7
- package/dist/chart-export.js +8 -8
- package/dist/row-drag-touch.d.ts +5 -1
- package/dist/row-drag-touch.js +15 -4
- package/dist/row-drag.js +7 -3
- package/package.json +11 -11
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +181 -181
- package/src/SvAutoComplete.svelte +169 -169
- package/src/SvAvatar.svelte +75 -75
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +102 -102
- package/src/SvCircularProgress.svelte +109 -109
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +279 -279
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +163 -163
- package/src/SvDrawer.svelte +254 -254
- package/src/SvDropDownList.svelte +378 -378
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.types.ts +2092 -2092
- package/src/SvGridCellEditor.svelte +744 -744
- package/src/SvGridChart.svelte +1716 -1716
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +728 -728
- package/src/SvGridSelect.svelte +270 -270
- package/src/SvGroupCell.svelte +116 -116
- package/src/SvListBox.svelte +334 -334
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +293 -293
- package/src/SvNumberInput.svelte +172 -172
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +133 -133
- package/src/SvPopover.svelte +197 -197
- package/src/SvProgress.svelte +116 -116
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRating.svelte +112 -112
- package/src/SvResult.svelte +73 -73
- package/src/SvRichText.svelte +211 -211
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +203 -203
- package/src/SvSwitchButton.svelte +108 -108
- package/src/SvTagsInput.svelte +115 -115
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +208 -208
- package/src/SvTree.svelte +444 -444
- package/src/SvTreeSelect.svelte +239 -239
- package/src/a11y/dismissable.test.ts +119 -119
- package/src/a11y/dismissable.ts +114 -114
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1419 -1419
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +846 -846
- package/src/builtin-editors.grid.test.ts +83 -83
- package/src/cell-formatting.ts +171 -171
- package/src/cell-render.test.ts +513 -513
- package/src/cell-render.ts +496 -496
- package/src/cell-values.ts +148 -148
- package/src/chart-export.ts +202 -202
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2321 -2321
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/column-groups.ts +78 -78
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1865 -1865
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createListbox.svelte.ts +250 -250
- package/src/createMenu.svelte.ts +224 -224
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +322 -322
- package/src/datetime/date-core.ts +206 -206
- package/src/datetime/date-restrict.ts +61 -61
- package/src/datetime/timezone.ts +135 -135
- package/src/dock-manager-model.ts +596 -596
- package/src/dock-model.ts +374 -374
- package/src/editing.test.ts +974 -974
- package/src/editing.ts +609 -609
- package/src/editor-contract.ts +171 -171
- package/src/editor-registry.grid.test.ts +144 -144
- package/src/editor-registry.ts +122 -122
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +601 -601
- package/src/filter-operators.ts +160 -160
- package/src/filtering/excel-filters.ts +325 -325
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/group-display.test.ts +167 -167
- package/src/group-display.ts +200 -200
- package/src/headless.ts +87 -87
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard-handlers.ts +270 -270
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +179 -179
- package/src/menus.ts +597 -597
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/positioning.ts +268 -268
- package/src/render-component.ts +28 -28
- package/src/row-drag-touch.ts +20 -5
- package/src/row-drag.test.ts +401 -353
- package/src/row-drag.ts +419 -415
- package/src/row-resize.test.ts +524 -524
- package/src/row-resize.ts +228 -228
- package/src/scheduler-ical.ts +181 -181
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +873 -873
- package/src/selection.test.ts +885 -885
- package/src/server-data-source.test.ts +383 -383
- package/src/server-data-source.ts +469 -469
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +488 -488
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/summaries.ts +113 -113
- package/src/svgrid-wrapper.types.ts +563 -563
- package/src/svgrid.async-editor-options.test.ts +273 -273
- package/src/svgrid.auto-row-height.test.ts +204 -204
- package/src/svgrid.behavior.test.ts +910 -910
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.comments-autocomplete.test.ts +127 -127
- package/src/svgrid.context-menu.test.ts +147 -147
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-depth.test.ts +163 -163
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +377 -377
- package/src/svgrid.filter-menu-scroll.test.ts +112 -112
- package/src/svgrid.grand-total.test.ts +188 -188
- package/src/svgrid.group-display-mode.test.ts +171 -171
- package/src/svgrid.group-footers.test.ts +121 -121
- package/src/svgrid.group-pagination.test.ts +153 -153
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.tree-data.test.ts +186 -186
- package/src/svgrid.wrapper.test.ts +63 -63
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +288 -288
- package/src/toast-store.svelte.ts +250 -250
- package/src/toast-store.test.ts +147 -147
- package/src/tree-row-model.test.ts +168 -168
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-localization.test.ts +113 -113
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +322 -322
|
@@ -1,383 +1,383 @@
|
|
|
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('dispose() during an in-flight fetch clears the loading state (#83)', async () => {
|
|
40
|
-
const src = makeSource(250, 20) // 20ms delay keeps the fetch in-flight
|
|
41
|
-
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
42
|
-
ctl.refresh()
|
|
43
|
-
expect(ctl.getState().loading).toBe(true)
|
|
44
|
-
ctl.dispose()
|
|
45
|
-
expect(ctl.getState().loading).toBe(false)
|
|
46
|
-
await new Promise((r) => setTimeout(r, 30)) // let the fetch settle
|
|
47
|
-
expect(ctl.getState().loading).toBe(false) // stays cleared, not stuck
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('setPage fetches the right slice', async () => {
|
|
51
|
-
const src = makeSource(250)
|
|
52
|
-
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
53
|
-
ctl.setPage(2)
|
|
54
|
-
await flush()
|
|
55
|
-
expect(ctl.getState().rows[0]).toEqual({ id: 100 })
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('setSort resets to page 0 and re-fetches', async () => {
|
|
59
|
-
const src = makeSource(250)
|
|
60
|
-
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
61
|
-
ctl.setPage(2)
|
|
62
|
-
await flush()
|
|
63
|
-
ctl.setSort([{ id: 'id', desc: true }])
|
|
64
|
-
await flush()
|
|
65
|
-
const s = ctl.getState()
|
|
66
|
-
expect(s.pageIndex).toBe(0)
|
|
67
|
-
expect(s.rows[0]).toEqual({ id: 249 }) // reversed
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('ignores a stale response when a newer request supersedes it', async () => {
|
|
71
|
-
// slow source: first call (page 0) resolves AFTER the second (page 1)
|
|
72
|
-
const all = Array.from({ length: 100 }, (_, i) => ({ id: i }))
|
|
73
|
-
let call = 0
|
|
74
|
-
const src: ServerDataSource<Row> = {
|
|
75
|
-
getRows: vi.fn(async (req) => {
|
|
76
|
-
call += 1
|
|
77
|
-
const delay = call === 1 ? 40 : 5 // first call is slow
|
|
78
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
79
|
-
return { rows: all.slice(req.startRow, req.endRow), rowCount: 100 }
|
|
80
|
-
}),
|
|
81
|
-
}
|
|
82
|
-
const ctl = createServerDataSource(src, { pageSize: 10, onChange: () => {} })
|
|
83
|
-
ctl.refresh() // page 0 (slow)
|
|
84
|
-
ctl.setPage(3) // page 30 (fast) - should win
|
|
85
|
-
await new Promise((r) => setTimeout(r, 80))
|
|
86
|
-
expect(ctl.getState().rows[0]).toEqual({ id: 30 })
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('dispose stops further updates', async () => {
|
|
90
|
-
const src = makeSource(50, 20)
|
|
91
|
-
const onChange = vi.fn()
|
|
92
|
-
const ctl = createServerDataSource(src, { pageSize: 10, onChange })
|
|
93
|
-
ctl.refresh()
|
|
94
|
-
ctl.dispose()
|
|
95
|
-
await new Promise((r) => setTimeout(r, 40))
|
|
96
|
-
// only the synchronous loading=true emit happened before dispose
|
|
97
|
-
expect(ctl.getState().rows).toHaveLength(0)
|
|
98
|
-
})
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
describe('createServerDataSource mutations', () => {
|
|
102
|
-
type URow = { id: number; name: string }
|
|
103
|
-
|
|
104
|
-
function writable() {
|
|
105
|
-
let store: URow[] = [
|
|
106
|
-
{ id: 0, name: 'a' },
|
|
107
|
-
{ id: 1, name: 'b' },
|
|
108
|
-
{ id: 2, name: 'c' },
|
|
109
|
-
]
|
|
110
|
-
return {
|
|
111
|
-
getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
|
|
112
|
-
rows: store.slice(req.startRow, req.endRow),
|
|
113
|
-
rowCount: store.length,
|
|
114
|
-
})),
|
|
115
|
-
createRow: vi.fn(async (input: Partial<URow>) => {
|
|
116
|
-
const row: URow = { id: store.length, name: input.name ?? '' }
|
|
117
|
-
store = [...store, row]
|
|
118
|
-
return row
|
|
119
|
-
}),
|
|
120
|
-
updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
|
|
121
|
-
store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
|
|
122
|
-
return store.find((r) => String(r.id) === id)!
|
|
123
|
-
}),
|
|
124
|
-
deleteRow: vi.fn(async (id: string) => {
|
|
125
|
-
store = store.filter((r) => String(r.id) !== id)
|
|
126
|
-
}),
|
|
127
|
-
} as unknown as ServerDataSource<URow> & {
|
|
128
|
-
getRows: ReturnType<typeof vi.fn>
|
|
129
|
-
createRow: ReturnType<typeof vi.fn>
|
|
130
|
-
updateRow: ReturnType<typeof vi.fn>
|
|
131
|
-
deleteRow: ReturnType<typeof vi.fn>
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
it('createRow writes through and refreshes the current page', async () => {
|
|
136
|
-
const src = writable()
|
|
137
|
-
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
138
|
-
ctl.refresh()
|
|
139
|
-
await flush()
|
|
140
|
-
const created = await ctl.createRow({ name: 'z' })
|
|
141
|
-
expect(created).toEqual({ id: 3, name: 'z' })
|
|
142
|
-
expect(src.createRow).toHaveBeenCalledWith({ name: 'z' })
|
|
143
|
-
// the follow-up refresh re-fetched, so total + rows reflect the write
|
|
144
|
-
expect(ctl.getState().total).toBe(4)
|
|
145
|
-
expect(ctl.getState().rows.some((r) => r.name === 'z')).toBe(true)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it('toggles `saving` around a mutation', async () => {
|
|
149
|
-
const src = writable()
|
|
150
|
-
const states: Array<{ saving: boolean }> = []
|
|
151
|
-
const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
|
|
152
|
-
const p = ctl.createRow({ name: 'x' })
|
|
153
|
-
expect(ctl.getState().saving).toBe(true) // synchronous emit before the await
|
|
154
|
-
await p
|
|
155
|
-
expect(ctl.getState().saving).toBe(false)
|
|
156
|
-
expect(states.some((s) => s.saving)).toBe(true)
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
it('updateRow and deleteRow call through and refresh', async () => {
|
|
160
|
-
const src = writable()
|
|
161
|
-
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
162
|
-
ctl.refresh()
|
|
163
|
-
await flush()
|
|
164
|
-
const updated = await ctl.updateRow('1', { name: 'B!' })
|
|
165
|
-
expect(updated).toEqual({ id: 1, name: 'B!' })
|
|
166
|
-
expect(src.updateRow).toHaveBeenCalledWith('1', { name: 'B!' })
|
|
167
|
-
expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
|
|
168
|
-
|
|
169
|
-
await ctl.deleteRow('0')
|
|
170
|
-
expect(src.deleteRow).toHaveBeenCalledWith('0')
|
|
171
|
-
expect(ctl.getState().total).toBe(2)
|
|
172
|
-
expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
it('rejects when the source has no matching writer', async () => {
|
|
176
|
-
const readonly: ServerDataSource<URow> = {
|
|
177
|
-
getRows: async () => ({ rows: [], rowCount: 0 }),
|
|
178
|
-
}
|
|
179
|
-
const ctl = createServerDataSource(readonly, { onChange: () => {} })
|
|
180
|
-
await expect(ctl.createRow({ name: 'x' })).rejects.toThrow(/does not implement createRow/)
|
|
181
|
-
await expect(ctl.updateRow('1', {})).rejects.toThrow(/does not implement updateRow/)
|
|
182
|
-
await expect(ctl.deleteRow('1')).rejects.toThrow(/does not implement deleteRow/)
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
it('rejects mutations after dispose, without calling the source', async () => {
|
|
186
|
-
const src = writable()
|
|
187
|
-
const ctl = createServerDataSource(src, { onChange: () => {} })
|
|
188
|
-
ctl.dispose()
|
|
189
|
-
await expect(ctl.deleteRow('0')).rejects.toThrow(/disposed/)
|
|
190
|
-
expect(src.deleteRow).not.toHaveBeenCalled()
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
it('leaves read state untouched when a mutation fails', async () => {
|
|
194
|
-
const src = writable()
|
|
195
|
-
src.createRow.mockRejectedValueOnce(new Error('boom'))
|
|
196
|
-
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
197
|
-
ctl.refresh()
|
|
198
|
-
await flush()
|
|
199
|
-
await expect(ctl.createRow({ name: 'x' })).rejects.toThrow('boom')
|
|
200
|
-
expect(ctl.getState().saving).toBe(false)
|
|
201
|
-
expect(ctl.getState().total).toBe(3) // unchanged; no refresh on failure
|
|
202
|
-
})
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
describe('createServerDataSource optimistic mutations', () => {
|
|
206
|
-
type URow = { id: number; name: string }
|
|
207
|
-
const opts = (onChange: (s: any) => void = () => {}) => ({
|
|
208
|
-
pageSize: 50,
|
|
209
|
-
optimistic: true,
|
|
210
|
-
getRowId: (r: URow) => String(r.id),
|
|
211
|
-
onChange,
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
function writable() {
|
|
215
|
-
let store: URow[] = [
|
|
216
|
-
{ id: 0, name: 'a' },
|
|
217
|
-
{ id: 1, name: 'b' },
|
|
218
|
-
{ id: 2, name: 'c' },
|
|
219
|
-
]
|
|
220
|
-
return {
|
|
221
|
-
getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
|
|
222
|
-
rows: store.slice(req.startRow, req.endRow),
|
|
223
|
-
rowCount: store.length,
|
|
224
|
-
})),
|
|
225
|
-
updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
|
|
226
|
-
store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
|
|
227
|
-
return store.find((r) => String(r.id) === id)!
|
|
228
|
-
}),
|
|
229
|
-
deleteRow: vi.fn(async (id: string) => {
|
|
230
|
-
store = store.filter((r) => String(r.id) !== id)
|
|
231
|
-
}),
|
|
232
|
-
} as any
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
it('applies an update to local rows immediately, before the server resolves', async () => {
|
|
236
|
-
const src = writable()
|
|
237
|
-
src.updateRow.mockImplementationOnce(
|
|
238
|
-
(id: string, patch: any) => new Promise((r) => setTimeout(() => r({ id: Number(id), ...patch }), 30)),
|
|
239
|
-
)
|
|
240
|
-
const ctl = createServerDataSource(src, opts())
|
|
241
|
-
ctl.refresh()
|
|
242
|
-
await flush()
|
|
243
|
-
const p = ctl.updateRow('1', { name: 'B!' })
|
|
244
|
-
// synchronously reflected, no refetch
|
|
245
|
-
expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
|
|
246
|
-
expect(ctl.getState().saving).toBe(true)
|
|
247
|
-
await p
|
|
248
|
-
expect(ctl.getState().saving).toBe(false)
|
|
249
|
-
// getRows only called once (the initial refresh) - no refetch for the update
|
|
250
|
-
expect(src.getRows).toHaveBeenCalledTimes(1)
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
it('rolls an update back on error', async () => {
|
|
254
|
-
const src = writable()
|
|
255
|
-
src.updateRow.mockRejectedValueOnce(new Error('nope'))
|
|
256
|
-
const ctl = createServerDataSource(src, opts())
|
|
257
|
-
ctl.refresh()
|
|
258
|
-
await flush()
|
|
259
|
-
await expect(ctl.updateRow('1', { name: 'B!' })).rejects.toThrow('nope')
|
|
260
|
-
expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('b') // restored
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
it('removes a row immediately on delete and decrements total, restoring on error', async () => {
|
|
264
|
-
const src = writable()
|
|
265
|
-
const ctl = createServerDataSource(src, opts())
|
|
266
|
-
ctl.refresh()
|
|
267
|
-
await flush()
|
|
268
|
-
|
|
269
|
-
const p = ctl.deleteRow('0')
|
|
270
|
-
expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
|
|
271
|
-
expect(ctl.getState().total).toBe(2)
|
|
272
|
-
await p
|
|
273
|
-
|
|
274
|
-
src.deleteRow.mockRejectedValueOnce(new Error('locked'))
|
|
275
|
-
await expect(ctl.deleteRow('1')).rejects.toThrow('locked')
|
|
276
|
-
expect(ctl.getState().rows.some((r) => r.id === 1)).toBe(true) // restored
|
|
277
|
-
expect(ctl.getState().total).toBe(2)
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it('falls back to a refetch when the row is not on the current page', async () => {
|
|
281
|
-
const src = writable()
|
|
282
|
-
const ctl = createServerDataSource(src, opts())
|
|
283
|
-
ctl.refresh()
|
|
284
|
-
await flush()
|
|
285
|
-
await ctl.updateRow('999', { name: 'ghost' }) // not present locally
|
|
286
|
-
// fell back to mutate() which refreshes: getRows called again
|
|
287
|
-
expect(src.getRows).toHaveBeenCalledTimes(2)
|
|
288
|
-
})
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
describe('advanced-filter expression contract', () => {
|
|
292
|
-
const EXPR = { kind: 'cmp', column: 'id', op: 'greaterThan', value: '5' } as never
|
|
293
|
-
|
|
294
|
-
/** A source that reports whether it honoured the expression. */
|
|
295
|
-
function exprSource(ack: boolean | undefined) {
|
|
296
|
-
const seen: unknown[] = []
|
|
297
|
-
const source: ServerDataSource<Row> = {
|
|
298
|
-
async getRows(req) {
|
|
299
|
-
seen.push(req.filterModel.expression)
|
|
300
|
-
return {
|
|
301
|
-
rows: [{ id: 1 }],
|
|
302
|
-
rowCount: 1,
|
|
303
|
-
...(ack === undefined ? {} : { appliedExpression: ack }),
|
|
304
|
-
}
|
|
305
|
-
},
|
|
306
|
-
}
|
|
307
|
-
return { source, seen }
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
it('forwards the expression to the source', async () => {
|
|
311
|
-
const { source, seen } = exprSource(true)
|
|
312
|
-
const ctl = createServerDataSource(source, { pageSize: 10, onChange: () => {} })
|
|
313
|
-
ctl.setFilter({ expression: EXPR })
|
|
314
|
-
await flush()
|
|
315
|
-
expect(seen.at(-1)).toEqual(EXPR)
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
it('is satisfied when the source acknowledges applying it', async () => {
|
|
319
|
-
const ctl = createServerDataSource(exprSource(true).source, { pageSize: 10, onChange: () => {} })
|
|
320
|
-
ctl.setFilter({ expression: EXPR })
|
|
321
|
-
await flush()
|
|
322
|
-
expect(ctl.getState().expressionUnapplied).toBe(false)
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
it('flags an unapplied expression when the ack is missing', async () => {
|
|
326
|
-
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
327
|
-
ctl.setFilter({ expression: EXPR })
|
|
328
|
-
await flush()
|
|
329
|
-
// The rows look perfectly normal, which is exactly why this has to be
|
|
330
|
-
// surfaced rather than inferred by the user.
|
|
331
|
-
expect(ctl.getState().expressionUnapplied).toBe(true)
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
it('flags it when the source explicitly declines', async () => {
|
|
335
|
-
const ctl = createServerDataSource(exprSource(false).source, { pageSize: 10, onChange: () => {} })
|
|
336
|
-
ctl.setFilter({ expression: EXPR })
|
|
337
|
-
await flush()
|
|
338
|
-
expect(ctl.getState().expressionUnapplied).toBe(true)
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
it('does NOT client-filter the loaded page as a fallback', async () => {
|
|
342
|
-
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
343
|
-
ctl.setFilter({ expression: EXPR })
|
|
344
|
-
await flush()
|
|
345
|
-
// id 1 fails `id > 5`. Filtering here would turn "1 of 1,000,000 match"
|
|
346
|
-
// into a confident lie and make paging incoherent, so the row survives and
|
|
347
|
-
// the flag carries the truth instead.
|
|
348
|
-
expect(ctl.getState().rows).toEqual([{ id: 1 }])
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
it('warns once, not on every fetch', async () => {
|
|
352
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
353
|
-
try {
|
|
354
|
-
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
355
|
-
ctl.setFilter({ expression: EXPR })
|
|
356
|
-
await flush()
|
|
357
|
-
ctl.refresh()
|
|
358
|
-
await flush()
|
|
359
|
-
ctl.refresh()
|
|
360
|
-
await flush()
|
|
361
|
-
expect(warn).toHaveBeenCalledTimes(1)
|
|
362
|
-
} finally {
|
|
363
|
-
warn.mockRestore()
|
|
364
|
-
}
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
it('stays clear when no expression is in play', async () => {
|
|
368
|
-
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
369
|
-
ctl.refresh()
|
|
370
|
-
await flush()
|
|
371
|
-
expect(ctl.getState().expressionUnapplied).toBe(false)
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
it('clears the flag once the expression is removed', async () => {
|
|
375
|
-
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
376
|
-
ctl.setFilter({ expression: EXPR })
|
|
377
|
-
await flush()
|
|
378
|
-
expect(ctl.getState().expressionUnapplied).toBe(true)
|
|
379
|
-
ctl.setFilter({})
|
|
380
|
-
await flush()
|
|
381
|
-
expect(ctl.getState().expressionUnapplied).toBe(false)
|
|
382
|
-
})
|
|
383
|
-
})
|
|
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('dispose() during an in-flight fetch clears the loading state (#83)', async () => {
|
|
40
|
+
const src = makeSource(250, 20) // 20ms delay keeps the fetch in-flight
|
|
41
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
42
|
+
ctl.refresh()
|
|
43
|
+
expect(ctl.getState().loading).toBe(true)
|
|
44
|
+
ctl.dispose()
|
|
45
|
+
expect(ctl.getState().loading).toBe(false)
|
|
46
|
+
await new Promise((r) => setTimeout(r, 30)) // let the fetch settle
|
|
47
|
+
expect(ctl.getState().loading).toBe(false) // stays cleared, not stuck
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('setPage fetches the right slice', async () => {
|
|
51
|
+
const src = makeSource(250)
|
|
52
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
53
|
+
ctl.setPage(2)
|
|
54
|
+
await flush()
|
|
55
|
+
expect(ctl.getState().rows[0]).toEqual({ id: 100 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('setSort resets to page 0 and re-fetches', async () => {
|
|
59
|
+
const src = makeSource(250)
|
|
60
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
61
|
+
ctl.setPage(2)
|
|
62
|
+
await flush()
|
|
63
|
+
ctl.setSort([{ id: 'id', desc: true }])
|
|
64
|
+
await flush()
|
|
65
|
+
const s = ctl.getState()
|
|
66
|
+
expect(s.pageIndex).toBe(0)
|
|
67
|
+
expect(s.rows[0]).toEqual({ id: 249 }) // reversed
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('ignores a stale response when a newer request supersedes it', async () => {
|
|
71
|
+
// slow source: first call (page 0) resolves AFTER the second (page 1)
|
|
72
|
+
const all = Array.from({ length: 100 }, (_, i) => ({ id: i }))
|
|
73
|
+
let call = 0
|
|
74
|
+
const src: ServerDataSource<Row> = {
|
|
75
|
+
getRows: vi.fn(async (req) => {
|
|
76
|
+
call += 1
|
|
77
|
+
const delay = call === 1 ? 40 : 5 // first call is slow
|
|
78
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
79
|
+
return { rows: all.slice(req.startRow, req.endRow), rowCount: 100 }
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
const ctl = createServerDataSource(src, { pageSize: 10, onChange: () => {} })
|
|
83
|
+
ctl.refresh() // page 0 (slow)
|
|
84
|
+
ctl.setPage(3) // page 30 (fast) - should win
|
|
85
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
86
|
+
expect(ctl.getState().rows[0]).toEqual({ id: 30 })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('dispose stops further updates', async () => {
|
|
90
|
+
const src = makeSource(50, 20)
|
|
91
|
+
const onChange = vi.fn()
|
|
92
|
+
const ctl = createServerDataSource(src, { pageSize: 10, onChange })
|
|
93
|
+
ctl.refresh()
|
|
94
|
+
ctl.dispose()
|
|
95
|
+
await new Promise((r) => setTimeout(r, 40))
|
|
96
|
+
// only the synchronous loading=true emit happened before dispose
|
|
97
|
+
expect(ctl.getState().rows).toHaveLength(0)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('createServerDataSource mutations', () => {
|
|
102
|
+
type URow = { id: number; name: string }
|
|
103
|
+
|
|
104
|
+
function writable() {
|
|
105
|
+
let store: URow[] = [
|
|
106
|
+
{ id: 0, name: 'a' },
|
|
107
|
+
{ id: 1, name: 'b' },
|
|
108
|
+
{ id: 2, name: 'c' },
|
|
109
|
+
]
|
|
110
|
+
return {
|
|
111
|
+
getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
|
|
112
|
+
rows: store.slice(req.startRow, req.endRow),
|
|
113
|
+
rowCount: store.length,
|
|
114
|
+
})),
|
|
115
|
+
createRow: vi.fn(async (input: Partial<URow>) => {
|
|
116
|
+
const row: URow = { id: store.length, name: input.name ?? '' }
|
|
117
|
+
store = [...store, row]
|
|
118
|
+
return row
|
|
119
|
+
}),
|
|
120
|
+
updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
|
|
121
|
+
store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
|
|
122
|
+
return store.find((r) => String(r.id) === id)!
|
|
123
|
+
}),
|
|
124
|
+
deleteRow: vi.fn(async (id: string) => {
|
|
125
|
+
store = store.filter((r) => String(r.id) !== id)
|
|
126
|
+
}),
|
|
127
|
+
} as unknown as ServerDataSource<URow> & {
|
|
128
|
+
getRows: ReturnType<typeof vi.fn>
|
|
129
|
+
createRow: ReturnType<typeof vi.fn>
|
|
130
|
+
updateRow: ReturnType<typeof vi.fn>
|
|
131
|
+
deleteRow: ReturnType<typeof vi.fn>
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it('createRow writes through and refreshes the current page', async () => {
|
|
136
|
+
const src = writable()
|
|
137
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
138
|
+
ctl.refresh()
|
|
139
|
+
await flush()
|
|
140
|
+
const created = await ctl.createRow({ name: 'z' })
|
|
141
|
+
expect(created).toEqual({ id: 3, name: 'z' })
|
|
142
|
+
expect(src.createRow).toHaveBeenCalledWith({ name: 'z' })
|
|
143
|
+
// the follow-up refresh re-fetched, so total + rows reflect the write
|
|
144
|
+
expect(ctl.getState().total).toBe(4)
|
|
145
|
+
expect(ctl.getState().rows.some((r) => r.name === 'z')).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('toggles `saving` around a mutation', async () => {
|
|
149
|
+
const src = writable()
|
|
150
|
+
const states: Array<{ saving: boolean }> = []
|
|
151
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
|
|
152
|
+
const p = ctl.createRow({ name: 'x' })
|
|
153
|
+
expect(ctl.getState().saving).toBe(true) // synchronous emit before the await
|
|
154
|
+
await p
|
|
155
|
+
expect(ctl.getState().saving).toBe(false)
|
|
156
|
+
expect(states.some((s) => s.saving)).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('updateRow and deleteRow call through and refresh', async () => {
|
|
160
|
+
const src = writable()
|
|
161
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
162
|
+
ctl.refresh()
|
|
163
|
+
await flush()
|
|
164
|
+
const updated = await ctl.updateRow('1', { name: 'B!' })
|
|
165
|
+
expect(updated).toEqual({ id: 1, name: 'B!' })
|
|
166
|
+
expect(src.updateRow).toHaveBeenCalledWith('1', { name: 'B!' })
|
|
167
|
+
expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
|
|
168
|
+
|
|
169
|
+
await ctl.deleteRow('0')
|
|
170
|
+
expect(src.deleteRow).toHaveBeenCalledWith('0')
|
|
171
|
+
expect(ctl.getState().total).toBe(2)
|
|
172
|
+
expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('rejects when the source has no matching writer', async () => {
|
|
176
|
+
const readonly: ServerDataSource<URow> = {
|
|
177
|
+
getRows: async () => ({ rows: [], rowCount: 0 }),
|
|
178
|
+
}
|
|
179
|
+
const ctl = createServerDataSource(readonly, { onChange: () => {} })
|
|
180
|
+
await expect(ctl.createRow({ name: 'x' })).rejects.toThrow(/does not implement createRow/)
|
|
181
|
+
await expect(ctl.updateRow('1', {})).rejects.toThrow(/does not implement updateRow/)
|
|
182
|
+
await expect(ctl.deleteRow('1')).rejects.toThrow(/does not implement deleteRow/)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('rejects mutations after dispose, without calling the source', async () => {
|
|
186
|
+
const src = writable()
|
|
187
|
+
const ctl = createServerDataSource(src, { onChange: () => {} })
|
|
188
|
+
ctl.dispose()
|
|
189
|
+
await expect(ctl.deleteRow('0')).rejects.toThrow(/disposed/)
|
|
190
|
+
expect(src.deleteRow).not.toHaveBeenCalled()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('leaves read state untouched when a mutation fails', async () => {
|
|
194
|
+
const src = writable()
|
|
195
|
+
src.createRow.mockRejectedValueOnce(new Error('boom'))
|
|
196
|
+
const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
|
|
197
|
+
ctl.refresh()
|
|
198
|
+
await flush()
|
|
199
|
+
await expect(ctl.createRow({ name: 'x' })).rejects.toThrow('boom')
|
|
200
|
+
expect(ctl.getState().saving).toBe(false)
|
|
201
|
+
expect(ctl.getState().total).toBe(3) // unchanged; no refresh on failure
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('createServerDataSource optimistic mutations', () => {
|
|
206
|
+
type URow = { id: number; name: string }
|
|
207
|
+
const opts = (onChange: (s: any) => void = () => {}) => ({
|
|
208
|
+
pageSize: 50,
|
|
209
|
+
optimistic: true,
|
|
210
|
+
getRowId: (r: URow) => String(r.id),
|
|
211
|
+
onChange,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
function writable() {
|
|
215
|
+
let store: URow[] = [
|
|
216
|
+
{ id: 0, name: 'a' },
|
|
217
|
+
{ id: 1, name: 'b' },
|
|
218
|
+
{ id: 2, name: 'c' },
|
|
219
|
+
]
|
|
220
|
+
return {
|
|
221
|
+
getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
|
|
222
|
+
rows: store.slice(req.startRow, req.endRow),
|
|
223
|
+
rowCount: store.length,
|
|
224
|
+
})),
|
|
225
|
+
updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
|
|
226
|
+
store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
|
|
227
|
+
return store.find((r) => String(r.id) === id)!
|
|
228
|
+
}),
|
|
229
|
+
deleteRow: vi.fn(async (id: string) => {
|
|
230
|
+
store = store.filter((r) => String(r.id) !== id)
|
|
231
|
+
}),
|
|
232
|
+
} as any
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
it('applies an update to local rows immediately, before the server resolves', async () => {
|
|
236
|
+
const src = writable()
|
|
237
|
+
src.updateRow.mockImplementationOnce(
|
|
238
|
+
(id: string, patch: any) => new Promise((r) => setTimeout(() => r({ id: Number(id), ...patch }), 30)),
|
|
239
|
+
)
|
|
240
|
+
const ctl = createServerDataSource(src, opts())
|
|
241
|
+
ctl.refresh()
|
|
242
|
+
await flush()
|
|
243
|
+
const p = ctl.updateRow('1', { name: 'B!' })
|
|
244
|
+
// synchronously reflected, no refetch
|
|
245
|
+
expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
|
|
246
|
+
expect(ctl.getState().saving).toBe(true)
|
|
247
|
+
await p
|
|
248
|
+
expect(ctl.getState().saving).toBe(false)
|
|
249
|
+
// getRows only called once (the initial refresh) - no refetch for the update
|
|
250
|
+
expect(src.getRows).toHaveBeenCalledTimes(1)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('rolls an update back on error', async () => {
|
|
254
|
+
const src = writable()
|
|
255
|
+
src.updateRow.mockRejectedValueOnce(new Error('nope'))
|
|
256
|
+
const ctl = createServerDataSource(src, opts())
|
|
257
|
+
ctl.refresh()
|
|
258
|
+
await flush()
|
|
259
|
+
await expect(ctl.updateRow('1', { name: 'B!' })).rejects.toThrow('nope')
|
|
260
|
+
expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('b') // restored
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('removes a row immediately on delete and decrements total, restoring on error', async () => {
|
|
264
|
+
const src = writable()
|
|
265
|
+
const ctl = createServerDataSource(src, opts())
|
|
266
|
+
ctl.refresh()
|
|
267
|
+
await flush()
|
|
268
|
+
|
|
269
|
+
const p = ctl.deleteRow('0')
|
|
270
|
+
expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
|
|
271
|
+
expect(ctl.getState().total).toBe(2)
|
|
272
|
+
await p
|
|
273
|
+
|
|
274
|
+
src.deleteRow.mockRejectedValueOnce(new Error('locked'))
|
|
275
|
+
await expect(ctl.deleteRow('1')).rejects.toThrow('locked')
|
|
276
|
+
expect(ctl.getState().rows.some((r) => r.id === 1)).toBe(true) // restored
|
|
277
|
+
expect(ctl.getState().total).toBe(2)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('falls back to a refetch when the row is not on the current page', async () => {
|
|
281
|
+
const src = writable()
|
|
282
|
+
const ctl = createServerDataSource(src, opts())
|
|
283
|
+
ctl.refresh()
|
|
284
|
+
await flush()
|
|
285
|
+
await ctl.updateRow('999', { name: 'ghost' }) // not present locally
|
|
286
|
+
// fell back to mutate() which refreshes: getRows called again
|
|
287
|
+
expect(src.getRows).toHaveBeenCalledTimes(2)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('advanced-filter expression contract', () => {
|
|
292
|
+
const EXPR = { kind: 'cmp', column: 'id', op: 'greaterThan', value: '5' } as never
|
|
293
|
+
|
|
294
|
+
/** A source that reports whether it honoured the expression. */
|
|
295
|
+
function exprSource(ack: boolean | undefined) {
|
|
296
|
+
const seen: unknown[] = []
|
|
297
|
+
const source: ServerDataSource<Row> = {
|
|
298
|
+
async getRows(req) {
|
|
299
|
+
seen.push(req.filterModel.expression)
|
|
300
|
+
return {
|
|
301
|
+
rows: [{ id: 1 }],
|
|
302
|
+
rowCount: 1,
|
|
303
|
+
...(ack === undefined ? {} : { appliedExpression: ack }),
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
return { source, seen }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
it('forwards the expression to the source', async () => {
|
|
311
|
+
const { source, seen } = exprSource(true)
|
|
312
|
+
const ctl = createServerDataSource(source, { pageSize: 10, onChange: () => {} })
|
|
313
|
+
ctl.setFilter({ expression: EXPR })
|
|
314
|
+
await flush()
|
|
315
|
+
expect(seen.at(-1)).toEqual(EXPR)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('is satisfied when the source acknowledges applying it', async () => {
|
|
319
|
+
const ctl = createServerDataSource(exprSource(true).source, { pageSize: 10, onChange: () => {} })
|
|
320
|
+
ctl.setFilter({ expression: EXPR })
|
|
321
|
+
await flush()
|
|
322
|
+
expect(ctl.getState().expressionUnapplied).toBe(false)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('flags an unapplied expression when the ack is missing', async () => {
|
|
326
|
+
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
327
|
+
ctl.setFilter({ expression: EXPR })
|
|
328
|
+
await flush()
|
|
329
|
+
// The rows look perfectly normal, which is exactly why this has to be
|
|
330
|
+
// surfaced rather than inferred by the user.
|
|
331
|
+
expect(ctl.getState().expressionUnapplied).toBe(true)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('flags it when the source explicitly declines', async () => {
|
|
335
|
+
const ctl = createServerDataSource(exprSource(false).source, { pageSize: 10, onChange: () => {} })
|
|
336
|
+
ctl.setFilter({ expression: EXPR })
|
|
337
|
+
await flush()
|
|
338
|
+
expect(ctl.getState().expressionUnapplied).toBe(true)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('does NOT client-filter the loaded page as a fallback', async () => {
|
|
342
|
+
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
343
|
+
ctl.setFilter({ expression: EXPR })
|
|
344
|
+
await flush()
|
|
345
|
+
// id 1 fails `id > 5`. Filtering here would turn "1 of 1,000,000 match"
|
|
346
|
+
// into a confident lie and make paging incoherent, so the row survives and
|
|
347
|
+
// the flag carries the truth instead.
|
|
348
|
+
expect(ctl.getState().rows).toEqual([{ id: 1 }])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('warns once, not on every fetch', async () => {
|
|
352
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
353
|
+
try {
|
|
354
|
+
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
355
|
+
ctl.setFilter({ expression: EXPR })
|
|
356
|
+
await flush()
|
|
357
|
+
ctl.refresh()
|
|
358
|
+
await flush()
|
|
359
|
+
ctl.refresh()
|
|
360
|
+
await flush()
|
|
361
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
362
|
+
} finally {
|
|
363
|
+
warn.mockRestore()
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('stays clear when no expression is in play', async () => {
|
|
368
|
+
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
369
|
+
ctl.refresh()
|
|
370
|
+
await flush()
|
|
371
|
+
expect(ctl.getState().expressionUnapplied).toBe(false)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('clears the flag once the expression is removed', async () => {
|
|
375
|
+
const ctl = createServerDataSource(exprSource(undefined).source, { pageSize: 10, onChange: () => {} })
|
|
376
|
+
ctl.setFilter({ expression: EXPR })
|
|
377
|
+
await flush()
|
|
378
|
+
expect(ctl.getState().expressionUnapplied).toBe(true)
|
|
379
|
+
ctl.setFilter({})
|
|
380
|
+
await flush()
|
|
381
|
+
expect(ctl.getState().expressionUnapplied).toBe(false)
|
|
382
|
+
})
|
|
383
|
+
})
|