@svgrid/grid 3.0.4 → 3.0.5
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 +4 -4
- package/dist/SvGrid.controller.svelte.d.ts +12 -2
- package/dist/SvGrid.controller.svelte.js +192 -8
- package/dist/SvGrid.css +73 -0
- package/dist/SvGrid.svelte +128 -3
- package/dist/SvGrid.types.d.ts +356 -3
- package/dist/build-api.js +11 -6
- package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-CID62rlI.js} +11 -11
- package/dist/cdn/{GridMenus-7kbpnnBW.js → GridMenus-DLL-qZeu.js} +11 -11
- package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
- package/dist/cdn/{src-BKhZ6eXd.js → src-Bklofsad.js} +8818 -8798
- package/dist/cdn/{src-V1uu8iE9.js → src-jcFMkBnN.js} +4282 -4262
- package/dist/cdn/svgrid.js +27 -26
- package/dist/cdn/svgrid.svelte-external.js +27 -26
- package/dist/cdn/validate-C7Rn_Bns.js +76 -0
- package/dist/core.js +36 -13
- package/dist/editing.js +10 -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 +6 -0
- package/dist/grid-messages.js +5 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/row-model.d.ts +133 -0
- package/dist/row-model.js +16 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +30 -2
- package/dist/server-block-cache.d.ts +214 -0
- package/dist/server-block-cache.js +531 -0
- package/dist/server-data-source.d.ts +240 -6
- package/dist/server-data-source.js +220 -20
- package/dist/server.d.ts +13 -0
- package/dist/server.js +13 -0
- package/dist/validate.d.ts +4 -0
- package/dist/validate.js +21 -3
- package/package.json +6 -1
- package/src/SvGrid.controller.svelte.ts +191 -8
- package/src/SvGrid.css +73 -0
- package/src/SvGrid.svelte +128 -3
- package/src/SvGrid.types.ts +376 -3
- package/src/build-api.ts +11 -4
- package/src/core.rowmodel-cache.test.ts +51 -0
- package/src/core.ts +33 -12
- package/src/editing.ts +8 -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.ts +12 -0
- package/src/icon-seam.test.ts +2 -4
- package/src/index.ts +36 -10
- package/src/row-model.ts +146 -0
- package/src/selection.test.ts +3 -0
- package/src/selection.ts +32 -2
- package/src/server-block-cache.test.ts +645 -0
- package/src/server-block-cache.ts +677 -0
- package/src/server-data-source.infinite.test.ts +343 -0
- package/src/server-data-source.ts +450 -27
- package/src/server.ts +47 -0
- package/src/svgrid.gantt-seam.test.ts +199 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
- package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
- package/src/svgrid.upsell-license.test.ts +7 -4
- package/src/validate.test.ts +29 -0
- package/src/validate.ts +28 -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/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
package/src/server.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@svgrid/grid/server` - the server-side data primitives, with no Svelte in
|
|
3
|
+
* the import graph.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is also on the package root. This entry point exists for
|
|
6
|
+
* code that must not pull `SvGrid.svelte` into its bundle or its test
|
|
7
|
+
* runner: a SvelteKit `+server.ts` shaping a `ServerRequest` into a query,
|
|
8
|
+
* a worker feeding a block cache, or `@svgrid/enterprise`'s own row model,
|
|
9
|
+
* whose unit tests run without the Svelte compiler.
|
|
10
|
+
*/
|
|
11
|
+
export {
|
|
12
|
+
createServerDataSource,
|
|
13
|
+
type ServerDataSource,
|
|
14
|
+
type ServerSelectionRule,
|
|
15
|
+
type ServerRequest,
|
|
16
|
+
type ServerResult,
|
|
17
|
+
type ServerController,
|
|
18
|
+
type ServerControllerOptions,
|
|
19
|
+
type ServerState,
|
|
20
|
+
type ServerSortModel,
|
|
21
|
+
type ServerFilterModel,
|
|
22
|
+
type ServerAggregation,
|
|
23
|
+
type ServerGroupRow,
|
|
24
|
+
type ServerLeafRow,
|
|
25
|
+
type ServerMoreRow,
|
|
26
|
+
type ServerFooterRow,
|
|
27
|
+
type ServerSkeletonRow,
|
|
28
|
+
type ServerGrandTotalRow,
|
|
29
|
+
type ServerPlaceholderRow,
|
|
30
|
+
type ServerDisplayRow,
|
|
31
|
+
} from './server-data-source'
|
|
32
|
+
export {
|
|
33
|
+
createBlockCache,
|
|
34
|
+
createRowPlaceholder,
|
|
35
|
+
rowPlaceholderState,
|
|
36
|
+
type BlockCache,
|
|
37
|
+
type BlockCacheOptions,
|
|
38
|
+
type BlockCacheState,
|
|
39
|
+
type BlockFetchResult,
|
|
40
|
+
type BlockState,
|
|
41
|
+
} from './server-block-cache'
|
|
42
|
+
export {
|
|
43
|
+
toServerFilterColumns,
|
|
44
|
+
type GridRowModel,
|
|
45
|
+
type GridFilterState,
|
|
46
|
+
type GridRowModelSort,
|
|
47
|
+
} from './row-model'
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FREE half of the Gantt view: the `gantt` prop, what the grid hands the
|
|
3
|
+
* renderer, and what shows when no Enterprise renderer is registered.
|
|
4
|
+
*
|
|
5
|
+
* The renderer itself is Pro and lives behind `registerGanttView`, so most of
|
|
6
|
+
* this suite deliberately never registers one - it asserts the upsell path.
|
|
7
|
+
* The last block registers a STUB renderer to pin the seam's contract: which
|
|
8
|
+
* props the grid passes, and that the rows arrive already filtered. The real
|
|
9
|
+
* renderer is covered by `gantt.dom.test.ts` in @svgrid/enterprise.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { mount, unmount } from 'svelte'
|
|
13
|
+
import SvGrid from './SvGrid.svelte'
|
|
14
|
+
import {
|
|
15
|
+
createCoreRowModel,
|
|
16
|
+
columnFilteringFeature,
|
|
17
|
+
hasGanttView,
|
|
18
|
+
registerGanttView,
|
|
19
|
+
rowSortingFeature,
|
|
20
|
+
tableFeatures,
|
|
21
|
+
} from './index'
|
|
22
|
+
import { getGanttView } from './gantt-view.svelte'
|
|
23
|
+
import GanttStub from './gantt-stub.test.svelte'
|
|
24
|
+
import type { ColumnDef, GanttConfig, SvGridApi } from './index'
|
|
25
|
+
|
|
26
|
+
type Task = { id: number; name: string; start: string; end: string; parentId: number | null }
|
|
27
|
+
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
|
|
28
|
+
const rows: Task[] = [
|
|
29
|
+
{ id: 1, name: 'Discovery', start: '2026-09-01', end: '2026-09-10', parentId: null },
|
|
30
|
+
{ id: 2, name: 'Interviews', start: '2026-09-01', end: '2026-09-04', parentId: 1 },
|
|
31
|
+
{ id: 3, name: 'Build', start: '2026-09-10', end: '2026-09-30', parentId: null },
|
|
32
|
+
]
|
|
33
|
+
const cols: ColumnDef<typeof features, Task>[] = [
|
|
34
|
+
{ field: 'name', header: 'Task', width: 180 },
|
|
35
|
+
{ field: 'start', header: 'Start', width: 120 },
|
|
36
|
+
{ field: 'end', header: 'End', width: 120 },
|
|
37
|
+
]
|
|
38
|
+
const gantt: GanttConfig<typeof features, Task> = {
|
|
39
|
+
startField: 'start',
|
|
40
|
+
endField: 'end',
|
|
41
|
+
parentField: 'parentId',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tick = () => new Promise<void>((r) => setTimeout(r))
|
|
45
|
+
const UPSELL = '.sv-grid-gantt-upsell'
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
// The seam is module-level state; a stub left registered would make every
|
|
49
|
+
// later "no renderer" assertion pass for the wrong reason.
|
|
50
|
+
registerGanttView(null as never)
|
|
51
|
+
document.body.innerHTML = ''
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
function mountGrid(extra: Record<string, unknown> = {}) {
|
|
55
|
+
return new Promise<{ api: SvGridApi<typeof features, Task>; target: HTMLElement; destroy: () => void }>(
|
|
56
|
+
(res, rej) => {
|
|
57
|
+
const target = document.createElement('div')
|
|
58
|
+
document.body.appendChild(target)
|
|
59
|
+
const app = mount(SvGrid, {
|
|
60
|
+
target,
|
|
61
|
+
props: {
|
|
62
|
+
data: rows,
|
|
63
|
+
columns: cols,
|
|
64
|
+
features,
|
|
65
|
+
_rowModels: { coreRowModel: createCoreRowModel() },
|
|
66
|
+
getRowId: (r: Task) => String(r.id),
|
|
67
|
+
containerHeight: 260,
|
|
68
|
+
virtualization: false,
|
|
69
|
+
gantt,
|
|
70
|
+
onApiReady(api: SvGridApi<typeof features, Task>) {
|
|
71
|
+
res({ api, target, destroy: () => { unmount(app); target.remove() } })
|
|
72
|
+
},
|
|
73
|
+
...extra,
|
|
74
|
+
} as any,
|
|
75
|
+
})
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
if (!target.querySelector('.sv-grid-gantt-root')) rej(new Error('no gantt root'))
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('gantt - without the Enterprise renderer', () => {
|
|
84
|
+
it('reports that no renderer is registered', () => {
|
|
85
|
+
// If this is ever true here, the suite is testing the Pro path by accident
|
|
86
|
+
// and every assertion below is meaningless.
|
|
87
|
+
expect(hasGanttView()).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders the upsell note in place of the table', async () => {
|
|
91
|
+
const { target, destroy } = await mountGrid()
|
|
92
|
+
await tick()
|
|
93
|
+
|
|
94
|
+
const note = target.querySelector(UPSELL)!
|
|
95
|
+
expect(note).not.toBeNull()
|
|
96
|
+
expect(note.getAttribute('role')).toBe('note')
|
|
97
|
+
expect(note.textContent).toContain('@svgrid/enterprise')
|
|
98
|
+
expect(note.textContent).toContain('enableGanttView()')
|
|
99
|
+
// The table is replaced, not decorated - this is a view of the grid.
|
|
100
|
+
expect(target.querySelector('[role="grid"]')).toBeNull()
|
|
101
|
+
destroy()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('says a license is needed, and links to it', async () => {
|
|
105
|
+
// Naming the package is what unblocks a developer trying it; saying a key
|
|
106
|
+
// is needed is what they have to know before shipping. Enterprise is
|
|
107
|
+
// SOFT-gated, so the wording must not imply the feature is inert without
|
|
108
|
+
// one - it says "watermark", which is what actually happens.
|
|
109
|
+
const { target, destroy } = await mountGrid()
|
|
110
|
+
await tick()
|
|
111
|
+
|
|
112
|
+
const license = target.querySelector('.sv-grid-upsell-license')!
|
|
113
|
+
expect(license).not.toBeNull()
|
|
114
|
+
expect(license.textContent).toContain('license key')
|
|
115
|
+
expect(license.textContent).toContain('watermark')
|
|
116
|
+
|
|
117
|
+
const link = license.querySelector('a')!
|
|
118
|
+
expect(link.getAttribute('href')).toBe('https://svgrid.com/pricing/')
|
|
119
|
+
expect(link.getAttribute('target')).toBe('_blank')
|
|
120
|
+
expect(link.getAttribute('rel')).toContain('noopener')
|
|
121
|
+
destroy()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('shows the search box, and hides it on `searchable: false`', async () => {
|
|
125
|
+
const withBox = await mountGrid()
|
|
126
|
+
await tick()
|
|
127
|
+
const input = withBox.target.querySelector<HTMLInputElement>('.sv-grid-board-search input')!
|
|
128
|
+
expect(input).not.toBeNull()
|
|
129
|
+
expect(input.placeholder).toBe('Search tasks...')
|
|
130
|
+
withBox.destroy()
|
|
131
|
+
|
|
132
|
+
const without = await mountGrid({ gantt: { ...gantt, searchable: false } })
|
|
133
|
+
await tick()
|
|
134
|
+
expect(without.target.querySelector('.sv-grid-board-search')).toBeNull()
|
|
135
|
+
without.destroy()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('is localizable, like the rest of the grid chrome', async () => {
|
|
139
|
+
const { target, destroy } = await mountGrid({
|
|
140
|
+
localization: {
|
|
141
|
+
text: { ganttUpsellTitle: 'Gantt-Ansicht', upsellLicense: 'Lizenz erforderlich.' },
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
await vi.waitFor(() => {
|
|
145
|
+
expect(target.querySelector(UPSELL)?.textContent).toContain('Gantt-Ansicht')
|
|
146
|
+
expect(target.querySelector('.sv-grid-upsell-license')?.textContent).toContain(
|
|
147
|
+
'Lizenz erforderlich.',
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
destroy()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('gantt - the renderer seam', () => {
|
|
155
|
+
// GanttStub stands in for @svgrid/enterprise's SvGridGantt: it writes every
|
|
156
|
+
// prop it receives into the DOM, so the grid's half of the contract is what
|
|
157
|
+
// those attributes say.
|
|
158
|
+
const stub = () => document.querySelector<HTMLElement>('.gantt-stub')!
|
|
159
|
+
|
|
160
|
+
it('mounts the registered renderer instead of the upsell', async () => {
|
|
161
|
+
registerGanttView(GanttStub as never)
|
|
162
|
+
expect(hasGanttView()).toBe(true)
|
|
163
|
+
expect(getGanttView()).not.toBeNull()
|
|
164
|
+
|
|
165
|
+
const { target, destroy } = await mountGrid()
|
|
166
|
+
await tick()
|
|
167
|
+
expect(target.querySelector(UPSELL)).toBeNull()
|
|
168
|
+
expect(target.querySelector('.gantt-stub')).not.toBeNull()
|
|
169
|
+
destroy()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('hands it data, columns, the config and getRowId', async () => {
|
|
173
|
+
registerGanttView(GanttStub as never)
|
|
174
|
+
const { destroy } = await mountGrid()
|
|
175
|
+
await tick()
|
|
176
|
+
|
|
177
|
+
expect(stub().dataset.ids).toBe('1,2,3')
|
|
178
|
+
expect(stub().dataset.cols).toBe(String(cols.length))
|
|
179
|
+
expect(stub().dataset.start).toBe('start')
|
|
180
|
+
expect(stub().dataset.parent).toBe('parentId')
|
|
181
|
+
expect(stub().dataset.hasGetRowId).toBe('true')
|
|
182
|
+
destroy()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("hands it the grid's FILTERED rows, so the search box flows through", async () => {
|
|
186
|
+
// This is what makes the Gantt a view of the grid rather than a second
|
|
187
|
+
// data path: the renderer never filters, it just draws what it is given.
|
|
188
|
+
registerGanttView(GanttStub as never)
|
|
189
|
+
const { target, destroy } = await mountGrid()
|
|
190
|
+
await tick()
|
|
191
|
+
|
|
192
|
+
const search = target.querySelector<HTMLInputElement>('.sv-grid-board-search input')!
|
|
193
|
+
search.value = 'Interviews'
|
|
194
|
+
search.dispatchEvent(new Event('input', { bubbles: true }))
|
|
195
|
+
|
|
196
|
+
await vi.waitFor(() => expect(stub().dataset.ids).toBe('2'))
|
|
197
|
+
destroy()
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -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
|
+
})
|