@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
|
@@ -16,9 +16,21 @@
|
|
|
16
16
|
* follow-up re-fetch of the current page lands.
|
|
17
17
|
*/
|
|
18
18
|
import type { GridPredicateExpr } from './filtering/predicate-expr'
|
|
19
|
+
import {
|
|
20
|
+
createBlockCache,
|
|
21
|
+
rowPlaceholderState,
|
|
22
|
+
type BlockCache,
|
|
23
|
+
type BlockState,
|
|
24
|
+
} from './server-block-cache'
|
|
25
|
+
import { toServerFilterColumns, type GridFilterState, type GridRowModel } from './row-model'
|
|
19
26
|
|
|
27
|
+
/** Sort clauses in priority order; `id` is the column id. */
|
|
20
28
|
export type ServerSortModel = Array<{ id: string; desc: boolean }>
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* What a request carries for filtering: the global search, the per-column
|
|
32
|
+
* operator filters, and the advanced-filter expression.
|
|
33
|
+
*/
|
|
22
34
|
export type ServerFilterModel = {
|
|
23
35
|
/** Free-text global search. */
|
|
24
36
|
global?: string
|
|
@@ -54,6 +66,11 @@ export type ServerFilterModel = {
|
|
|
54
66
|
/** A value column to roll up per group. */
|
|
55
67
|
export type ServerAggregation = { col: string; fn: 'sum' | 'avg' | 'min' | 'max' | 'count' }
|
|
56
68
|
|
|
69
|
+
/**
|
|
70
|
+
* One range of rows as the grid asks for it. A flat request carries the
|
|
71
|
+
* range, the sort and the filter; a grouped request adds `groupBy`,
|
|
72
|
+
* `groupKeys` and `aggregations`; a pivoted one `pivotBy` and `pivotMode`.
|
|
73
|
+
*/
|
|
57
74
|
export type ServerRequest = {
|
|
58
75
|
/** Zero-based index of the first row wanted (inclusive). */
|
|
59
76
|
startRow: number
|
|
@@ -78,6 +95,33 @@ export type ServerRequest = {
|
|
|
78
95
|
groupKeys?: string[]
|
|
79
96
|
/** Value columns to aggregate per group. */
|
|
80
97
|
aggregations?: ServerAggregation[]
|
|
98
|
+
/**
|
|
99
|
+
* Server-side pivot: the columns whose distinct values become columns.
|
|
100
|
+
* With `pivotMode` on, a group row carries one value per (pivot key x
|
|
101
|
+
* aggregation) under a field named `<key>_<col>` (the separator is the
|
|
102
|
+
* row model's `pivotFieldSeparator`), and the response lists those
|
|
103
|
+
* fields in `pivotResultFields`. Only the Enterprise row model sets it.
|
|
104
|
+
*/
|
|
105
|
+
pivotBy?: string[]
|
|
106
|
+
pivotMode?: boolean
|
|
107
|
+
/**
|
|
108
|
+
* True on a top-level request when the grid wants a grand-total row and
|
|
109
|
+
* does not have one cached. Answer with `ServerResult.grandTotal`. Only
|
|
110
|
+
* the Enterprise server row model sets it.
|
|
111
|
+
*/
|
|
112
|
+
needsGrandTotal?: boolean
|
|
113
|
+
/**
|
|
114
|
+
* The group (or tree node) row being expanded, when this request is for
|
|
115
|
+
* its children. Handy for backends that key children off something on
|
|
116
|
+
* the parent rather than off `groupKeys`. Absent at the top level.
|
|
117
|
+
*/
|
|
118
|
+
parentRow?: unknown
|
|
119
|
+
/**
|
|
120
|
+
* Whatever the app passed as `context` to its controller, forwarded
|
|
121
|
+
* untouched on every request. Keep it JSON-serialisable: the SvelteKit
|
|
122
|
+
* transport posts the whole request to your endpoint.
|
|
123
|
+
*/
|
|
124
|
+
context?: unknown
|
|
81
125
|
}
|
|
82
126
|
|
|
83
127
|
/**
|
|
@@ -101,14 +145,29 @@ export type ServerGroupRow<TData> = {
|
|
|
101
145
|
loading: boolean
|
|
102
146
|
/** Aggregate values keyed by column id, read from the group's response row. */
|
|
103
147
|
aggregates: Record<string, unknown>
|
|
148
|
+
/**
|
|
149
|
+
* How many rows this group holds, when the backend said (see the row
|
|
150
|
+
* model's `childCount` option). Drawn next to the key by `SvGroupCell`,
|
|
151
|
+
* and used to size the group's scrollbar before its first block lands.
|
|
152
|
+
*/
|
|
153
|
+
childCount?: number
|
|
154
|
+
/**
|
|
155
|
+
* `false` when nothing can open beneath this row: the innermost group
|
|
156
|
+
* level under a server-side pivot, whose rows are the result itself.
|
|
157
|
+
* The group cell then draws no expander.
|
|
158
|
+
*/
|
|
159
|
+
expandable?: boolean
|
|
104
160
|
/** The raw response row for this group (key + aggregates), for cell rendering. */
|
|
105
161
|
data: TData
|
|
106
162
|
}
|
|
107
163
|
|
|
164
|
+
/** A data row in the display list. */
|
|
108
165
|
export type ServerLeafRow<TData> = {
|
|
109
166
|
kind: 'leaf'
|
|
110
167
|
id: string
|
|
111
168
|
level: number
|
|
169
|
+
/** The group path this leaf sits under. Set by the block-cached row model. */
|
|
170
|
+
route?: string[]
|
|
112
171
|
data: TData
|
|
113
172
|
}
|
|
114
173
|
|
|
@@ -152,17 +211,59 @@ export type ServerSkeletonRow = {
|
|
|
152
211
|
level: number
|
|
153
212
|
}
|
|
154
213
|
|
|
155
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* A grand-total row across the whole result. One per grid, at the top or
|
|
216
|
+
* the bottom, with the fixed id `sv-grand-total` so a transaction can
|
|
217
|
+
* address it.
|
|
218
|
+
*/
|
|
219
|
+
export type ServerGrandTotalRow<TData> = {
|
|
220
|
+
kind: 'grandTotal'
|
|
221
|
+
id: 'sv-grand-total'
|
|
222
|
+
level: 0
|
|
223
|
+
aggregates: Record<string, unknown>
|
|
224
|
+
data: TData
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A row whose data has not arrived: `loading` while its block is in
|
|
229
|
+
* flight, `failed` when the fetch rejected. Shared, frozen objects - one
|
|
230
|
+
* per state, not one per row - so a million unloaded rows cost nothing to
|
|
231
|
+
* represent. They also carry the grid's placeholder mark, so
|
|
232
|
+
* `rowPlaceholderState()` recognises them and the grid draws them itself.
|
|
233
|
+
*/
|
|
234
|
+
export type ServerPlaceholderRow = {
|
|
235
|
+
readonly kind: 'placeholder'
|
|
236
|
+
readonly state: 'loading' | 'failed'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Every row a server row model can put on screen. Each carries an `id` and
|
|
241
|
+
* a `level`. The block-cached model adds {@link ServerPlaceholderRow} for
|
|
242
|
+
* rows not yet loaded - see its own `ServerRowModelDisplayRow`.
|
|
243
|
+
*/
|
|
156
244
|
export type ServerDisplayRow<TData> =
|
|
157
245
|
| ServerGroupRow<TData>
|
|
158
246
|
| ServerLeafRow<TData>
|
|
159
247
|
| ServerMoreRow
|
|
160
248
|
| ServerFooterRow<TData>
|
|
161
249
|
| ServerSkeletonRow
|
|
250
|
+
| ServerGrandTotalRow<TData>
|
|
162
251
|
|
|
252
|
+
/**
|
|
253
|
+
* What `getRows` answers with: the rows for the requested range and the
|
|
254
|
+
* count after filtering, plus the grand total and the pivot fields when
|
|
255
|
+
* asked for.
|
|
256
|
+
*/
|
|
163
257
|
export type ServerResult<TData> = {
|
|
164
258
|
rows: ReadonlyArray<TData>
|
|
165
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Total row count after filtering, for the pager and the scrollbar.
|
|
261
|
+
*
|
|
262
|
+
* Paging needs it. Infinite scrolling does not: send `-1` (or, from a
|
|
263
|
+
* source typed loosely, omit it) when counting is expensive, and the grid
|
|
264
|
+
* discovers the end from the first short block instead. See
|
|
265
|
+
* `mode: 'infinite'` on {@link ServerControllerOptions}.
|
|
266
|
+
*/
|
|
166
267
|
rowCount: number
|
|
167
268
|
/**
|
|
168
269
|
* Set `true` ONLY when `filterModel.expression` was applied in full. Leave it
|
|
@@ -170,8 +271,50 @@ export type ServerResult<TData> = {
|
|
|
170
271
|
* pretending the filter ran. See the contract on `ServerFilterModel.expression`.
|
|
171
272
|
*/
|
|
172
273
|
appliedExpression?: boolean
|
|
274
|
+
/**
|
|
275
|
+
* The grand-total row, in reply to `ServerRequest.needsGrandTotal`: an
|
|
276
|
+
* object sets it, `null` removes it, and leaving it out keeps whatever the
|
|
277
|
+
* grid already had. Shaped like a group row: the aggregate values live
|
|
278
|
+
* under their column ids.
|
|
279
|
+
*/
|
|
280
|
+
grandTotal?: TData | null
|
|
281
|
+
/**
|
|
282
|
+
* Pivot mode: the fields the group rows carry for the pivoted values,
|
|
283
|
+
* e.g. `["2024_amount", "2025_amount"]`, from which the grid builds one
|
|
284
|
+
* column per field under a header group per pivot key. Send the full
|
|
285
|
+
* list on every pivoted response; the grid keeps the union. Every group
|
|
286
|
+
* row carries every listed field, `null` where no rows fall in that
|
|
287
|
+
* cell.
|
|
288
|
+
*/
|
|
289
|
+
pivotResultFields?: string[]
|
|
290
|
+
/**
|
|
291
|
+
* Pivot mode, the long way: full column definitions instead of field
|
|
292
|
+
* names, for a backend that wants to name and format them itself. Wins
|
|
293
|
+
* over `pivotResultFields` when both are present.
|
|
294
|
+
*/
|
|
295
|
+
pivotResultColumns?: ReadonlyArray<unknown>
|
|
173
296
|
}
|
|
174
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A selection expressed as a rule rather than a list, for `updateWhere`.
|
|
300
|
+
* The flat shape is "these ids" or "everything except these"; the nested
|
|
301
|
+
* shape is the same idea per group, keyed by group key or leaf id, as the
|
|
302
|
+
* Enterprise row model keeps it under `groupSelects: 'descendants'`.
|
|
303
|
+
*/
|
|
304
|
+
export type ServerSelectionRule =
|
|
305
|
+
| { selectAll: boolean; toggled: string[] }
|
|
306
|
+
| {
|
|
307
|
+
selectAllChildren: boolean
|
|
308
|
+
toggled: Record<string, { selectAllChildren: boolean; toggled: Record<string, unknown>; group?: boolean }>
|
|
309
|
+
group?: boolean
|
|
310
|
+
/** The group columns the tree's levels are keyed by, outer to inner. */
|
|
311
|
+
groupBy?: string[]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The contract a backend implements: `getRows`, and optionally the write
|
|
316
|
+
* methods, a bulk edit by rule and `destroy`.
|
|
317
|
+
*/
|
|
175
318
|
export type ServerDataSource<TData> = {
|
|
176
319
|
getRows(request: ServerRequest): Promise<ServerResult<TData>>
|
|
177
320
|
/**
|
|
@@ -183,8 +326,33 @@ export type ServerDataSource<TData> = {
|
|
|
183
326
|
createRow?(input: Partial<TData>): Promise<TData>
|
|
184
327
|
updateRow?(id: string, patch: Partial<TData>): Promise<TData>
|
|
185
328
|
deleteRow?(id: string): Promise<void>
|
|
329
|
+
/**
|
|
330
|
+
* Apply one patch to every row a selection RULE names, server-side - the
|
|
331
|
+
* write behind a bulk edit of rows the grid never loaded. `filterModel`
|
|
332
|
+
* scopes the rows exactly as `getRows` does; `selection` is the rule:
|
|
333
|
+
* either "these ids" (`selectAll: false`) or "everything but these"
|
|
334
|
+
* (`selectAll: true`), or the per-group tree the Enterprise row model
|
|
335
|
+
* keeps under `groupSelects: 'descendants'`. Resolve with how many rows
|
|
336
|
+
* changed. Optional: without it, a bulk edit under select-all is refused
|
|
337
|
+
* rather than silently applied to the loaded rows only.
|
|
338
|
+
*/
|
|
339
|
+
updateWhere?(
|
|
340
|
+
filterModel: ServerFilterModel,
|
|
341
|
+
patch: Partial<TData>,
|
|
342
|
+
selection: ServerSelectionRule,
|
|
343
|
+
): Promise<number>
|
|
344
|
+
/**
|
|
345
|
+
* Called once, from the controller's `dispose()`, for a source with
|
|
346
|
+
* something to close: a socket, a subscription, a worker.
|
|
347
|
+
*/
|
|
348
|
+
destroy?(): void
|
|
186
349
|
}
|
|
187
350
|
|
|
351
|
+
/**
|
|
352
|
+
* What `createServerDataSource` emits on every change: the rows on hand,
|
|
353
|
+
* the counts, the loading and saving flags, the last error, and the
|
|
354
|
+
* current sort and filter.
|
|
355
|
+
*/
|
|
188
356
|
export type ServerState<TData> = {
|
|
189
357
|
rows: ReadonlyArray<TData>
|
|
190
358
|
total: number
|
|
@@ -206,13 +374,51 @@ export type ServerState<TData> = {
|
|
|
206
374
|
* compiling; the controller always sets it.
|
|
207
375
|
*/
|
|
208
376
|
expressionUnapplied?: boolean
|
|
377
|
+
/**
|
|
378
|
+
* Rows after filtering, or `null` when the backend has not said and the
|
|
379
|
+
* end has not been found yet. Only ever null in `infinite` mode; `total`
|
|
380
|
+
* carries the best current guess either way.
|
|
381
|
+
*/
|
|
382
|
+
rowCount?: number | null
|
|
383
|
+
/** False while the end of an infinite list is still being discovered. */
|
|
384
|
+
lastRowKnown?: boolean
|
|
385
|
+
/** Blocks whose fetch failed, in `infinite` mode. Empty when all is well. */
|
|
386
|
+
failedBlocks?: number[]
|
|
209
387
|
}
|
|
210
388
|
|
|
211
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Everything a controller can do, plus the {@link GridRowModel} surface, so
|
|
391
|
+
* one object can be driven by hand OR handed to `<SvGrid rowModel>` and wire
|
|
392
|
+
* itself up.
|
|
393
|
+
*/
|
|
394
|
+
export type ServerController<TData> = GridRowModel<TData> & {
|
|
212
395
|
/** Re-fetch the current page (e.g. after a mutation). */
|
|
213
396
|
refresh(): void
|
|
397
|
+
/**
|
|
398
|
+
* The rows on screen, so `infinite` mode knows which blocks to fetch and
|
|
399
|
+
* which to spare from eviction. Wire it to the grid's
|
|
400
|
+
* `onVisibleRangeChange` - or pass the controller as `rowModel` and the
|
|
401
|
+
* grid wires it for you. No-op in `page` mode.
|
|
402
|
+
*/
|
|
403
|
+
setViewport(startIndex: number, endIndex: number): void
|
|
404
|
+
/** Re-fetch the blocks that failed. No-op in `page` mode. */
|
|
405
|
+
retryLoads(): void
|
|
406
|
+
/**
|
|
407
|
+
* Throw away every cached block and reload from the current viewport.
|
|
408
|
+
* `refresh()` is the gentler option: it keeps the row count and the scroll
|
|
409
|
+
* position. No-op in `page` mode, where there is only ever one page held.
|
|
410
|
+
*/
|
|
411
|
+
purge(): void
|
|
412
|
+
/** Cached blocks and their state, for logging and tests. Empty in `page` mode. */
|
|
413
|
+
getCacheState(): BlockState[]
|
|
214
414
|
setSort(sortModel: ServerSortModel): void
|
|
215
|
-
|
|
415
|
+
/**
|
|
416
|
+
* Replace the filter. Takes the `ServerFilterModel` a request carries, or
|
|
417
|
+
* the payload the grid's own `onFiltersChange` hands you - which is a
|
|
418
|
+
* list of columns rather than a map, and is converted here so that every
|
|
419
|
+
* app does not write the same `Object.fromEntries` by hand.
|
|
420
|
+
*/
|
|
421
|
+
setFilter(filterModel: ServerFilterModel | GridFilterState): void
|
|
216
422
|
setPage(pageIndex: number): void
|
|
217
423
|
setPageSize(pageSize: number): void
|
|
218
424
|
/**
|
|
@@ -237,10 +443,14 @@ export type ServerController<TData> = {
|
|
|
237
443
|
dispose(): void
|
|
238
444
|
}
|
|
239
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Options for `createServerDataSource`: the page size, or the block
|
|
448
|
+
* settings in `infinite` mode, optimistic writes and the change callback.
|
|
449
|
+
*/
|
|
240
450
|
export type ServerControllerOptions<TData> = {
|
|
241
451
|
pageSize?: number
|
|
242
452
|
/** Called whenever any of `rows` / `total` / `loading` / page changes. */
|
|
243
|
-
onChange
|
|
453
|
+
onChange?: (state: ServerState<TData>) => void
|
|
244
454
|
/**
|
|
245
455
|
* Apply `updateRow` / `deleteRow` to the local rows immediately (before the
|
|
246
456
|
* server confirms) and roll back on error - so edits feel instant and no
|
|
@@ -250,8 +460,40 @@ export type ServerControllerOptions<TData> = {
|
|
|
250
460
|
optimistic?: boolean
|
|
251
461
|
/** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */
|
|
252
462
|
getRowId?: (row: TData) => string
|
|
463
|
+
/**
|
|
464
|
+
* How rows reach the grid.
|
|
465
|
+
*
|
|
466
|
+
* - `page` (default): one page at a time. `state.rows` is that page, and
|
|
467
|
+
* `setPage` moves between them.
|
|
468
|
+
* - `infinite`: one long scrollable list. `state.rows` spans the whole
|
|
469
|
+
* result, with placeholder rows standing in for blocks nobody has
|
|
470
|
+
* scrolled to yet; blocks load as the viewport reaches them.
|
|
471
|
+
*/
|
|
472
|
+
mode?: 'page' | 'infinite'
|
|
473
|
+
/** `infinite` mode: rows per request. Default 100. */
|
|
474
|
+
blockSize?: number
|
|
475
|
+
/**
|
|
476
|
+
* `infinite` mode: keep at most this many loaded blocks, evicting the
|
|
477
|
+
* least recently seen. Unlimited by default.
|
|
478
|
+
*/
|
|
479
|
+
maxBlocksInCache?: number
|
|
480
|
+
/** `infinite` mode: requests open at once. Default 2. */
|
|
481
|
+
maxConcurrentRequests?: number
|
|
482
|
+
/** `infinite` mode: wait for the scroll to settle this long before fetching. */
|
|
483
|
+
blockLoadDebounceMs?: number
|
|
484
|
+
/**
|
|
485
|
+
* `infinite` mode: rows to claim before anything has loaded, so there is a
|
|
486
|
+
* scrollbar on first paint. Default 1.
|
|
487
|
+
*/
|
|
488
|
+
initialRowCount?: number
|
|
253
489
|
}
|
|
254
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The free server row model over a `ServerDataSource`: one page at a time,
|
|
493
|
+
* or one block-cached list in `infinite` mode, with sort, filter, race
|
|
494
|
+
* safety and writes. The result is a `GridRowModel`, so
|
|
495
|
+
* `<SvGrid rowModel={ctl} />` wires every seam.
|
|
496
|
+
*/
|
|
255
497
|
export function createServerDataSource<TData>(
|
|
256
498
|
source: ServerDataSource<TData>,
|
|
257
499
|
options: ServerControllerOptions<TData>,
|
|
@@ -270,6 +512,14 @@ export function createServerDataSource<TData>(
|
|
|
270
512
|
expressionUnapplied: false,
|
|
271
513
|
}
|
|
272
514
|
|
|
515
|
+
const infinite = options.mode === 'infinite'
|
|
516
|
+
// `onChange` is the one-callback API this controller shipped with;
|
|
517
|
+
// `subscribe` is the many-listener one `GridRowModel` needs. Both fire
|
|
518
|
+
// from `emit`, so a grid driven by `rowModel` and an app reading
|
|
519
|
+
// `onChange` stay in step.
|
|
520
|
+
const subscribers = new Set<() => void>()
|
|
521
|
+
let cache: BlockCache<TData> | null = null
|
|
522
|
+
|
|
273
523
|
// Monotonic request id: only the latest fetch is allowed to land, so a slow
|
|
274
524
|
// response for an old sort/filter can't clobber a newer one.
|
|
275
525
|
let requestSeq = 0
|
|
@@ -280,7 +530,88 @@ export function createServerDataSource<TData>(
|
|
|
280
530
|
|
|
281
531
|
const emit = () => {
|
|
282
532
|
state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))
|
|
283
|
-
options.onChange({ ...state })
|
|
533
|
+
options.onChange?.({ ...state })
|
|
534
|
+
for (const notify of subscribers) notify()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Pull the block cache's view of the world into `state` and emit.
|
|
539
|
+
*
|
|
540
|
+
* `total` stays a plain number because the pager and the footer have
|
|
541
|
+
* always read it as one; while the end is undiscovered it holds the
|
|
542
|
+
* current optimistic length, which is exactly what the scrollbar needs.
|
|
543
|
+
* `rowCount` is the honest answer, null and all.
|
|
544
|
+
*/
|
|
545
|
+
function emitFromCache(): void {
|
|
546
|
+
if (!cache || disposed) return
|
|
547
|
+
const rows = cache.rows()
|
|
548
|
+
state.rows = rows
|
|
549
|
+
state.rowCount = cache.rowCount()
|
|
550
|
+
state.lastRowKnown = cache.lastRowKnown()
|
|
551
|
+
state.total = cache.rowCount() ?? rows.length
|
|
552
|
+
emit()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function buildCache(): BlockCache<TData> {
|
|
556
|
+
return createBlockCache<TData>({
|
|
557
|
+
blockSize: options.blockSize ?? 100,
|
|
558
|
+
maxBlocksInCache: options.maxBlocksInCache,
|
|
559
|
+
maxConcurrentRequests: options.maxConcurrentRequests,
|
|
560
|
+
blockLoadDebounceMs: options.blockLoadDebounceMs,
|
|
561
|
+
initialRowCount: options.initialRowCount,
|
|
562
|
+
fetch: async (startRow, endRow) => {
|
|
563
|
+
const result = await source.getRows({
|
|
564
|
+
startRow,
|
|
565
|
+
endRow,
|
|
566
|
+
// A block is a page of its own size, so a backend that only knows
|
|
567
|
+
// how to page still works unchanged.
|
|
568
|
+
pageIndex: Math.floor(startRow / Math.max(1, endRow - startRow)),
|
|
569
|
+
pageSize: endRow - startRow,
|
|
570
|
+
sortModel: state.sortModel,
|
|
571
|
+
filterModel: state.filterModel,
|
|
572
|
+
groupBy: [],
|
|
573
|
+
groupKeys: [],
|
|
574
|
+
aggregations: [],
|
|
575
|
+
})
|
|
576
|
+
noteExpressionApplied(result)
|
|
577
|
+
return { rows: result.rows, rowCount: result.rowCount }
|
|
578
|
+
},
|
|
579
|
+
onChange: (s) => {
|
|
580
|
+
state.loading = s.loading
|
|
581
|
+
state.failedBlocks = s.failedBlocks
|
|
582
|
+
emitFromCache()
|
|
583
|
+
},
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Reload from scratch: new sort, new filter, or an explicit purge. */
|
|
588
|
+
function resetCache(): void {
|
|
589
|
+
if (disposed) return
|
|
590
|
+
cache?.dispose()
|
|
591
|
+
cache = buildCache()
|
|
592
|
+
cache.setViewport(viewStart, viewEnd)
|
|
593
|
+
emitFromCache()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* An expression was sent but the backend did not acknowledge applying it,
|
|
598
|
+
* so these rows are a SUPERSET of what was asked for. Say so instead of
|
|
599
|
+
* filtering here: filtering one page would turn "3 of 1,000,000 match"
|
|
600
|
+
* into a confident lie and make paging incoherent, since the next page
|
|
601
|
+
* would re-filter a different slice.
|
|
602
|
+
*/
|
|
603
|
+
function noteExpressionApplied(result: ServerResult<TData>): void {
|
|
604
|
+
state.expressionUnapplied =
|
|
605
|
+
state.filterModel.expression != null && result.appliedExpression !== true
|
|
606
|
+
if (!state.expressionUnapplied || warnedExpressionUnapplied) return
|
|
607
|
+
warnedExpressionUnapplied = true
|
|
608
|
+
console.warn(
|
|
609
|
+
'[svgrid] The data source was sent filterModel.expression but did not ' +
|
|
610
|
+
'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
|
|
611
|
+
'and the rows shown are unfiltered. Apply the whole expression and ' +
|
|
612
|
+
'acknowledge it, or clear the advanced filter. ' +
|
|
613
|
+
'See https://svgrid.com/docs/help/server/server-filtering',
|
|
614
|
+
)
|
|
284
615
|
}
|
|
285
616
|
|
|
286
617
|
async function fetchPage() {
|
|
@@ -298,7 +629,8 @@ export function createServerDataSource<TData>(
|
|
|
298
629
|
pageSize: state.pageSize,
|
|
299
630
|
sortModel: state.sortModel,
|
|
300
631
|
filterModel: state.filterModel,
|
|
301
|
-
// Flat mode: no grouping.
|
|
632
|
+
// Flat mode: no grouping. Server-side grouping and tree data live in
|
|
633
|
+
// `createServerGroupModel` from @svgrid/enterprise, which fills these in.
|
|
302
634
|
groupBy: [],
|
|
303
635
|
groupKeys: [],
|
|
304
636
|
aggregations: [],
|
|
@@ -306,23 +638,9 @@ export function createServerDataSource<TData>(
|
|
|
306
638
|
if (disposed || id !== requestSeq) return // stale
|
|
307
639
|
state.rows = result.rows
|
|
308
640
|
state.total = result.rowCount
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// "3 of 1,000,000 match" into a confident lie and make paging incoherent,
|
|
313
|
-
// since page 2 would re-filter a different slice.
|
|
314
|
-
state.expressionUnapplied =
|
|
315
|
-
state.filterModel.expression != null && result.appliedExpression !== true
|
|
316
|
-
if (state.expressionUnapplied && !warnedExpressionUnapplied) {
|
|
317
|
-
warnedExpressionUnapplied = true
|
|
318
|
-
console.warn(
|
|
319
|
-
'[svgrid] The data source was sent filterModel.expression but did not ' +
|
|
320
|
-
'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
|
|
321
|
-
'and the rows shown are unfiltered. Apply the whole expression and ' +
|
|
322
|
-
'acknowledge it, or clear the advanced filter. ' +
|
|
323
|
-
'See https://svgrid.com/docs/help/server/server-filtering',
|
|
324
|
-
)
|
|
325
|
-
}
|
|
641
|
+
state.rowCount = result.rowCount
|
|
642
|
+
state.lastRowKnown = true
|
|
643
|
+
noteExpressionApplied(result)
|
|
326
644
|
state.loading = false
|
|
327
645
|
emit()
|
|
328
646
|
} catch (err) {
|
|
@@ -351,7 +669,10 @@ export function createServerDataSource<TData>(
|
|
|
351
669
|
return (async () => {
|
|
352
670
|
try {
|
|
353
671
|
const result = await thunk()
|
|
354
|
-
|
|
672
|
+
// Page mode re-reads the page. Infinite mode re-reads the blocks it
|
|
673
|
+
// holds, in place: the list keeps its length and its scroll.
|
|
674
|
+
if (infinite) cache?.refresh()
|
|
675
|
+
else await fetchPage()
|
|
355
676
|
return result
|
|
356
677
|
} finally {
|
|
357
678
|
state.saving = false
|
|
@@ -363,6 +684,11 @@ export function createServerDataSource<TData>(
|
|
|
363
684
|
const optimistic = !!options.optimistic && !!options.getRowId
|
|
364
685
|
const getRowId = options.getRowId
|
|
365
686
|
|
|
687
|
+
// The last range the grid reported, so a sort / filter / purge can
|
|
688
|
+
// re-request what the user is actually looking at rather than row 0.
|
|
689
|
+
let viewStart = 0
|
|
690
|
+
let viewEnd = 0
|
|
691
|
+
|
|
366
692
|
// Optimistic update: patch the local row, reconcile with the server result,
|
|
367
693
|
// roll back on error. Falls back to the plain refresh path when the row
|
|
368
694
|
// isn't on the current page (nothing local to update).
|
|
@@ -372,6 +698,27 @@ export function createServerDataSource<TData>(
|
|
|
372
698
|
fn: (id: string, patch: Partial<TData>) => Promise<TData>,
|
|
373
699
|
): Promise<TData> {
|
|
374
700
|
if (disposed) throw new Error('createServerDataSource: controller is disposed')
|
|
701
|
+
if (infinite && cache) {
|
|
702
|
+
// The cache owns the rows here; patch it, not a copy the next block
|
|
703
|
+
// to land would overwrite.
|
|
704
|
+
const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)
|
|
705
|
+
if (at < 0) return mutate('updateRow', () => fn(id, patch))
|
|
706
|
+
const prev = cache.getRow(at) as TData
|
|
707
|
+
cache.patch(at, { ...prev, ...patch })
|
|
708
|
+
state.saving = true
|
|
709
|
+
emit()
|
|
710
|
+
try {
|
|
711
|
+
const result = await fn(id, patch)
|
|
712
|
+
cache.patch(at, result)
|
|
713
|
+
return result
|
|
714
|
+
} catch (err) {
|
|
715
|
+
cache.patch(at, prev)
|
|
716
|
+
throw err
|
|
717
|
+
} finally {
|
|
718
|
+
state.saving = false
|
|
719
|
+
emit()
|
|
720
|
+
}
|
|
721
|
+
}
|
|
375
722
|
const prevRows = state.rows
|
|
376
723
|
const idx = prevRows.findIndex((r) => getRowId!(r) === id)
|
|
377
724
|
if (idx < 0) return mutate('updateRow', () => fn(id, patch))
|
|
@@ -398,6 +745,24 @@ export function createServerDataSource<TData>(
|
|
|
398
745
|
fn: (id: string) => Promise<void>,
|
|
399
746
|
): Promise<void> {
|
|
400
747
|
if (disposed) throw new Error('createServerDataSource: controller is disposed')
|
|
748
|
+
if (infinite && cache) {
|
|
749
|
+
const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)
|
|
750
|
+
if (at < 0) return mutate('deleteRow', () => fn(id))
|
|
751
|
+
const prev = cache.getRow(at) as TData
|
|
752
|
+
cache.remove(at, 1)
|
|
753
|
+
state.saving = true
|
|
754
|
+
emit()
|
|
755
|
+
try {
|
|
756
|
+
await fn(id)
|
|
757
|
+
} catch (err) {
|
|
758
|
+
cache.insert(at, [prev])
|
|
759
|
+
throw err
|
|
760
|
+
} finally {
|
|
761
|
+
state.saving = false
|
|
762
|
+
emit()
|
|
763
|
+
}
|
|
764
|
+
return
|
|
765
|
+
}
|
|
401
766
|
const prevRows = state.rows
|
|
402
767
|
const prevTotal = state.total
|
|
403
768
|
const next = prevRows.filter((r) => getRowId!(r) !== id)
|
|
@@ -419,8 +784,53 @@ export function createServerDataSource<TData>(
|
|
|
419
784
|
}
|
|
420
785
|
}
|
|
421
786
|
|
|
787
|
+
// In infinite mode the first blocks are requested as soon as the grid
|
|
788
|
+
// reports a viewport; `refresh()` is what arms the cache before that.
|
|
789
|
+
if (infinite) cache = buildCache()
|
|
790
|
+
|
|
422
791
|
return {
|
|
423
|
-
refresh:
|
|
792
|
+
refresh: () => {
|
|
793
|
+
if (!infinite) return void fetchPage()
|
|
794
|
+
// Keep the count and the scroll position, re-request what is held.
|
|
795
|
+
cache?.refresh()
|
|
796
|
+
},
|
|
797
|
+
setViewport(startIndex, endIndex) {
|
|
798
|
+
viewStart = startIndex
|
|
799
|
+
viewEnd = endIndex
|
|
800
|
+
cache?.setViewport(startIndex, endIndex)
|
|
801
|
+
},
|
|
802
|
+
retryLoads: () => cache?.retryFailed(),
|
|
803
|
+
purge: () => cache?.purge(),
|
|
804
|
+
getCacheState: () => cache?.getCacheState() ?? [],
|
|
805
|
+
|
|
806
|
+
// --- GridRowModel -----------------------------------------------
|
|
807
|
+
subscribe(onChange) {
|
|
808
|
+
subscribers.add(onChange)
|
|
809
|
+
return () => {
|
|
810
|
+
subscribers.delete(onChange)
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
getRows: () => state.rows,
|
|
814
|
+
isLoading: () => state.loading,
|
|
815
|
+
getRowId: options.getRowId ? (row: TData) => options.getRowId!(row) : undefined,
|
|
816
|
+
// Only infinite mode has unloaded rows to stand in for.
|
|
817
|
+
rowPlaceholder: infinite ? (row: TData) => rowPlaceholderState(row) : undefined,
|
|
818
|
+
retryRow: infinite ? () => cache?.retryFailed() : undefined,
|
|
819
|
+
/**
|
|
820
|
+
* A getter, not a snapshot: the grid re-reads this after every change
|
|
821
|
+
* notification, and a frozen object would leave the pager on page 1
|
|
822
|
+
* forever. Absent in infinite mode, where there are no pages.
|
|
823
|
+
*/
|
|
824
|
+
get pagination() {
|
|
825
|
+
if (infinite) return undefined
|
|
826
|
+
return {
|
|
827
|
+
pageIndex: state.pageIndex,
|
|
828
|
+
pageSize: state.pageSize,
|
|
829
|
+
rowCount: state.total,
|
|
830
|
+
setPage: (pageIndex: number) => this.setPage(pageIndex),
|
|
831
|
+
setPageSize: (pageSize: number) => this.setPageSize(pageSize),
|
|
832
|
+
}
|
|
833
|
+
},
|
|
424
834
|
createRow: (input) =>
|
|
425
835
|
mutate('createRow', source.createRow ? () => source.createRow!(input) : null),
|
|
426
836
|
updateRow: (id, patch) => {
|
|
@@ -436,14 +846,24 @@ export function createServerDataSource<TData>(
|
|
|
436
846
|
setSort(sortModel) {
|
|
437
847
|
state.sortModel = sortModel
|
|
438
848
|
state.pageIndex = 0
|
|
849
|
+
if (infinite) return resetCache()
|
|
439
850
|
void fetchPage()
|
|
440
851
|
},
|
|
441
852
|
setFilter(filterModel) {
|
|
442
|
-
state.filterModel = filterModel
|
|
853
|
+
state.filterModel = Array.isArray(filterModel.columns)
|
|
854
|
+
? {
|
|
855
|
+
global: filterModel.global,
|
|
856
|
+
columns: toServerFilterColumns(filterModel as GridFilterState),
|
|
857
|
+
}
|
|
858
|
+
: (filterModel as ServerFilterModel)
|
|
443
859
|
state.pageIndex = 0
|
|
860
|
+
if (infinite) return resetCache()
|
|
444
861
|
void fetchPage()
|
|
445
862
|
},
|
|
446
863
|
setPage(pageIndex) {
|
|
864
|
+
// There are no pages to move between when the whole result is one
|
|
865
|
+
// scrollable list; the grid scrolls instead.
|
|
866
|
+
if (infinite) return
|
|
447
867
|
const clamped = Math.max(0, pageIndex)
|
|
448
868
|
if (clamped === state.pageIndex) return
|
|
449
869
|
state.pageIndex = clamped
|
|
@@ -452,11 +872,14 @@ export function createServerDataSource<TData>(
|
|
|
452
872
|
setPageSize(pageSize) {
|
|
453
873
|
state.pageSize = Math.max(1, pageSize)
|
|
454
874
|
state.pageIndex = 0
|
|
875
|
+
if (infinite) return
|
|
455
876
|
void fetchPage()
|
|
456
877
|
},
|
|
457
878
|
getState: () => ({ ...state }),
|
|
458
879
|
dispose() {
|
|
459
880
|
disposed = true
|
|
881
|
+
cache?.dispose()
|
|
882
|
+
cache = null
|
|
460
883
|
// An in-flight fetch's resolution short-circuits on `disposed`, so it
|
|
461
884
|
// never clears `loading`. Clear it here (and emit) so a disposed
|
|
462
885
|
// controller doesn't report a permanent loading state.
|