@svgrid/grid 3.0.4 → 3.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +32 -11
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +33 -5
  4. package/dist/SvGrid.controller.svelte.js +397 -58
  5. package/dist/SvGrid.css +200 -2
  6. package/dist/SvGrid.svelte +350 -40
  7. package/dist/SvGrid.types.d.ts +433 -7
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +92 -37
  15. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-C-NtjlrJ.js} +155 -146
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/server-block-cache-DIqb3VZn.js +241 -0
  45. package/dist/cdn/{src-V1uu8iE9.js → src-CgoeSM8k.js} +5339 -4753
  46. package/dist/cdn/{src-BKhZ6eXd.js → src-DfwxB1TH.js} +9817 -9231
  47. package/dist/cdn/svgrid.js +28 -27
  48. package/dist/cdn/svgrid.svelte-external.js +28 -27
  49. package/dist/cdn/validate-AZoD8BoR.js +80 -0
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +401 -38
  59. package/dist/editing.js +34 -0
  60. package/dist/gantt-view.svelte.d.ts +24 -0
  61. package/dist/gantt-view.svelte.js +13 -0
  62. package/dist/grid-icons.d.ts +2 -2
  63. package/dist/grid-icons.js +2 -2
  64. package/dist/grid-messages.d.ts +10 -0
  65. package/dist/grid-messages.js +9 -0
  66. package/dist/headless.d.ts +5 -0
  67. package/dist/headless.js +12 -0
  68. package/dist/history.d.ts +21 -0
  69. package/dist/history.js +34 -4
  70. package/dist/index.d.ts +5 -4
  71. package/dist/index.js +3 -3
  72. package/dist/keyboard-handlers.js +24 -1
  73. package/dist/keyboard.js +4 -0
  74. package/dist/row-drag-touch.d.ts +1 -1
  75. package/dist/row-drag.js +35 -1
  76. package/dist/row-model.d.ts +139 -0
  77. package/dist/row-model.js +16 -0
  78. package/dist/row-resize.js +22 -1
  79. package/dist/selection.d.ts +2 -1
  80. package/dist/selection.js +31 -3
  81. package/dist/server-block-cache.d.ts +219 -0
  82. package/dist/server-block-cache.js +539 -0
  83. package/dist/server-data-source.d.ts +274 -8
  84. package/dist/server-data-source.js +229 -20
  85. package/dist/server.d.ts +13 -0
  86. package/dist/server.js +13 -0
  87. package/dist/sparkline.js +7 -5
  88. package/dist/svgrid-wrapper.types.d.ts +33 -0
  89. package/dist/validate.d.ts +4 -0
  90. package/dist/validate.js +29 -3
  91. package/package.json +11 -1
  92. package/src/GridMenus.svelte +34 -4
  93. package/src/SvGrid.controller.svelte.ts +397 -61
  94. package/src/SvGrid.css +200 -2
  95. package/src/SvGrid.svelte +350 -40
  96. package/src/SvGrid.types.ts +453 -7
  97. package/src/SvGridDropdown.svelte +20 -0
  98. package/src/SvModal.svelte +22 -0
  99. package/src/SvModal.test.ts +30 -1
  100. package/src/a11y/dismissable.test.ts +10 -1
  101. package/src/a11y/dismissable.ts +22 -2
  102. package/src/build-api.ts +96 -31
  103. package/src/cell-formatting.test.ts +17 -0
  104. package/src/cell-formatting.ts +18 -1
  105. package/src/cell-render.test.ts +24 -2
  106. package/src/cell-render.ts +24 -8
  107. package/src/clipboard.test.ts +107 -0
  108. package/src/clipboard.ts +62 -23
  109. package/src/columns.test.ts +4 -4
  110. package/src/columns.ts +7 -2
  111. package/src/conditional-formatting.test.ts +8 -0
  112. package/src/conditional-formatting.ts +3 -0
  113. package/src/core.rowmodel-cache.test.ts +51 -0
  114. package/src/core.tick-repair.test.ts +287 -0
  115. package/src/core.ts +407 -38
  116. package/src/editing.test.ts +52 -0
  117. package/src/editing.ts +33 -0
  118. package/src/gantt-stub.test.svelte +38 -0
  119. package/src/gantt-view.svelte.ts +35 -0
  120. package/src/grid-icons.ts +2 -2
  121. package/src/grid-messages.test.ts +7 -0
  122. package/src/grid-messages.ts +22 -0
  123. package/src/headless.ts +24 -0
  124. package/src/history.test.ts +49 -1
  125. package/src/history.ts +48 -2
  126. package/src/icon-seam.test.ts +2 -4
  127. package/src/index.ts +38 -10
  128. package/src/keyboard-handlers.coverage.test.ts +65 -0
  129. package/src/keyboard-handlers.ts +26 -1
  130. package/src/keyboard.ts +3 -0
  131. package/src/row-drag-touch.ts +2 -2
  132. package/src/row-drag.test.ts +51 -0
  133. package/src/row-drag.ts +35 -2
  134. package/src/row-model.ts +152 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.test.ts +3 -0
  137. package/src/selection.ts +33 -3
  138. package/src/server-block-cache.test.ts +672 -0
  139. package/src/server-block-cache.ts +690 -0
  140. package/src/server-data-source.infinite.test.ts +405 -0
  141. package/src/server-data-source.test.ts +19 -0
  142. package/src/server-data-source.ts +496 -29
  143. package/src/server.ts +49 -0
  144. package/src/sparkline.test.ts +7 -0
  145. package/src/sparkline.ts +6 -4
  146. package/src/svgrid-wrapper.types.ts +32 -0
  147. package/src/svgrid.api.test.ts +21 -0
  148. package/src/svgrid.charting.test.ts +12 -4
  149. package/src/svgrid.context-menu.test.ts +12 -0
  150. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  151. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  152. package/src/svgrid.gantt-seam.test.ts +199 -0
  153. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  156. package/src/svgrid.row-model-seam.svelte.test.ts +305 -0
  157. package/src/svgrid.row-pinning.test.ts +15 -1
  158. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  159. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  160. package/src/svgrid.upsell-license.test.ts +8 -5
  161. package/src/test-setup.ts +12 -0
  162. package/src/transaction.test.ts +58 -0
  163. package/src/validate.test.ts +29 -0
  164. package/src/validate.ts +34 -3
  165. package/dist/SvGroupCell.svelte +0 -141
  166. package/dist/SvGroupCell.svelte.d.ts +0 -49
  167. package/dist/SvRowGroupPanel.svelte +0 -186
  168. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  169. package/dist/cdn/GridMenus-7kbpnnBW.js +0 -635
  170. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  171. package/dist/server-group-model.d.ts +0 -98
  172. package/dist/server-group-model.js +0 -263
  173. package/src/SvGroupCell.svelte +0 -141
  174. package/src/SvRowGroupPanel.svelte +0 -186
  175. package/src/server-group-model.test.ts +0 -294
  176. 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
@@ -51,9 +63,23 @@ export type ServerFilterModel = {
51
63
  expression?: GridPredicateExpr
52
64
  }
53
65
 
54
- /** A value column to roll up per group. */
55
- export type ServerAggregation = { col: string; fn: 'sum' | 'avg' | 'min' | 'max' | 'count' }
66
+ /** The aggregate functions every backend helper understands. */
67
+ export type ServerAggFn = 'sum' | 'avg' | 'min' | 'max' | 'count'
68
+
69
+ /**
70
+ * One value column to roll up per group. `fn` is one of the five built-ins
71
+ * or any name your backend knows (`median`, `countDistinct`, a domain
72
+ * aggregate): the request carries it untouched. The SvelteKit planner emits
73
+ * only the built-ins plus the names it was told to allow, since `fn` ends
74
+ * up in SQL.
75
+ */
76
+ export type ServerAggregation = { col: string; fn: ServerAggFn | (string & {}) }
56
77
 
78
+ /**
79
+ * One range of rows as the grid asks for it. A flat request carries the
80
+ * range, the sort and the filter; a grouped request adds `groupBy`,
81
+ * `groupKeys` and `aggregations`; a pivoted one `pivotBy` and `pivotMode`.
82
+ */
57
83
  export type ServerRequest = {
58
84
  /** Zero-based index of the first row wanted (inclusive). */
59
85
  startRow: number
@@ -78,6 +104,43 @@ export type ServerRequest = {
78
104
  groupKeys?: string[]
79
105
  /** Value columns to aggregate per group. */
80
106
  aggregations?: ServerAggregation[]
107
+ /**
108
+ * Server-side pivot: the columns whose distinct values become columns.
109
+ * With `pivotMode` on, a group row carries one value per (pivot key x
110
+ * aggregation) under a field named `<key>_<col>` (the separator is the
111
+ * row model's `pivotFieldSeparator`), and the response lists those
112
+ * fields in `pivotResultFields`. Only the Enterprise row model sets it.
113
+ */
114
+ pivotBy?: string[]
115
+ pivotMode?: boolean
116
+ /**
117
+ * True on a top-level request when the grid wants a grand-total row and
118
+ * does not have one cached. Answer with `ServerResult.grandTotal`. Only
119
+ * the Enterprise server row model sets it.
120
+ */
121
+ needsGrandTotal?: boolean
122
+ /**
123
+ * The group (or tree node) row being expanded, when this request is for
124
+ * its children. Handy for backends that key children off something on
125
+ * the parent rather than off `groupKeys`. Absent at the top level.
126
+ */
127
+ parentRow?: unknown
128
+ /**
129
+ * Whatever the app passed as `context` to its controller, forwarded
130
+ * untouched on every request. Keep it JSON-serialisable: the SvelteKit
131
+ * transport posts the whole request to your endpoint.
132
+ */
133
+ context?: unknown
134
+ /**
135
+ * Aborted when the grid no longer wants the answer: the block was purged
136
+ * or evicted, the sort or filter changed, a group was collapsed, the
137
+ * controller was disposed. Hand it to `fetch` and a query the user has
138
+ * scrolled past is cancelled at the server instead of completing for
139
+ * nothing; a rejection carrying the abort is ignored by the grid. Not
140
+ * serialisable - the SvelteKit transport reads it and leaves it out of
141
+ * the body.
142
+ */
143
+ signal?: AbortSignal
81
144
  }
82
145
 
83
146
  /**
@@ -101,14 +164,29 @@ export type ServerGroupRow<TData> = {
101
164
  loading: boolean
102
165
  /** Aggregate values keyed by column id, read from the group's response row. */
103
166
  aggregates: Record<string, unknown>
167
+ /**
168
+ * How many rows this group holds, when the backend said (see the row
169
+ * model's `childCount` option). Drawn next to the key by `SvGroupCell`,
170
+ * and used to size the group's scrollbar before its first block lands.
171
+ */
172
+ childCount?: number
173
+ /**
174
+ * `false` when nothing can open beneath this row: the innermost group
175
+ * level under a server-side pivot, whose rows are the result itself.
176
+ * The group cell then draws no expander.
177
+ */
178
+ expandable?: boolean
104
179
  /** The raw response row for this group (key + aggregates), for cell rendering. */
105
180
  data: TData
106
181
  }
107
182
 
183
+ /** A data row in the display list. */
108
184
  export type ServerLeafRow<TData> = {
109
185
  kind: 'leaf'
110
186
  id: string
111
187
  level: number
188
+ /** The group path this leaf sits under. Set by the block-cached row model. */
189
+ route?: string[]
112
190
  data: TData
113
191
  }
114
192
 
@@ -152,17 +230,75 @@ export type ServerSkeletonRow = {
152
230
  level: number
153
231
  }
154
232
 
155
- /** A row in the flattened server-side group/tree display list. */
233
+ /**
234
+ * A grand-total row across the whole result. One per grid, at the top or
235
+ * the bottom, with the fixed id `sv-grand-total` so a transaction can
236
+ * address it.
237
+ */
238
+ export type ServerGrandTotalRow<TData> = {
239
+ kind: 'grandTotal'
240
+ id: 'sv-grand-total'
241
+ level: 0
242
+ aggregates: Record<string, unknown>
243
+ data: TData
244
+ }
245
+
246
+ /**
247
+ * A row whose data has not arrived: `loading` while its block is in
248
+ * flight, `failed` when the fetch rejected. Shared, frozen objects - one
249
+ * per state, not one per row - so a million unloaded rows cost nothing to
250
+ * represent. They also carry the grid's placeholder mark, so
251
+ * `rowPlaceholderState()` recognises them and the grid draws them itself.
252
+ */
253
+ export type ServerPlaceholderRow = {
254
+ readonly kind: 'placeholder'
255
+ readonly state: 'loading' | 'failed'
256
+ }
257
+
258
+ /**
259
+ * The detail panel under a leaf whose detail is open: master-detail on a
260
+ * server-side row model. The grid draws it through `renderDetailRow`
261
+ * (mark it with `isDetailRow`); `master` is the leaf it belongs to.
262
+ */
263
+ export type ServerDetailRow<TData> = {
264
+ kind: 'detail'
265
+ id: string
266
+ level: number
267
+ route?: string[]
268
+ /** The leaf's id, as `getRowId` names it. */
269
+ masterId: string
270
+ master: TData
271
+ }
272
+
273
+ /**
274
+ * Every row a server row model can put on screen. Each carries an `id` and
275
+ * a `level`. The block-cached model adds {@link ServerPlaceholderRow} for
276
+ * rows not yet loaded - see its own `ServerRowModelDisplayRow`.
277
+ */
156
278
  export type ServerDisplayRow<TData> =
157
279
  | ServerGroupRow<TData>
158
280
  | ServerLeafRow<TData>
281
+ | ServerDetailRow<TData>
159
282
  | ServerMoreRow
160
283
  | ServerFooterRow<TData>
161
284
  | ServerSkeletonRow
285
+ | ServerGrandTotalRow<TData>
162
286
 
287
+ /**
288
+ * What `getRows` answers with: the rows for the requested range and the
289
+ * count after filtering, plus the grand total and the pivot fields when
290
+ * asked for.
291
+ */
163
292
  export type ServerResult<TData> = {
164
293
  rows: ReadonlyArray<TData>
165
- /** Total row count after filtering (for the pager). */
294
+ /**
295
+ * Total row count after filtering, for the pager and the scrollbar.
296
+ *
297
+ * Paging needs it. Infinite scrolling does not: send `-1` (or, from a
298
+ * source typed loosely, omit it) when counting is expensive, and the grid
299
+ * discovers the end from the first short block instead. See
300
+ * `mode: 'infinite'` on {@link ServerControllerOptions}.
301
+ */
166
302
  rowCount: number
167
303
  /**
168
304
  * Set `true` ONLY when `filterModel.expression` was applied in full. Leave it
@@ -170,8 +306,50 @@ export type ServerResult<TData> = {
170
306
  * pretending the filter ran. See the contract on `ServerFilterModel.expression`.
171
307
  */
172
308
  appliedExpression?: boolean
309
+ /**
310
+ * The grand-total row, in reply to `ServerRequest.needsGrandTotal`: an
311
+ * object sets it, `null` removes it, and leaving it out keeps whatever the
312
+ * grid already had. Shaped like a group row: the aggregate values live
313
+ * under their column ids.
314
+ */
315
+ grandTotal?: TData | null
316
+ /**
317
+ * Pivot mode: the fields the group rows carry for the pivoted values,
318
+ * e.g. `["2024_amount", "2025_amount"]`, from which the grid builds one
319
+ * column per field under a header group per pivot key. Send the full
320
+ * list on every pivoted response; the grid keeps the union. Every group
321
+ * row carries every listed field, `null` where no rows fall in that
322
+ * cell.
323
+ */
324
+ pivotResultFields?: string[]
325
+ /**
326
+ * Pivot mode, the long way: full column definitions instead of field
327
+ * names, for a backend that wants to name and format them itself. Wins
328
+ * over `pivotResultFields` when both are present.
329
+ */
330
+ pivotResultColumns?: ReadonlyArray<unknown>
173
331
  }
174
332
 
333
+ /**
334
+ * A selection expressed as a rule rather than a list, for `updateWhere`.
335
+ * The flat shape is "these ids" or "everything except these"; the nested
336
+ * shape is the same idea per group, keyed by group key or leaf id, as the
337
+ * Enterprise row model keeps it under `groupSelects: 'descendants'`.
338
+ */
339
+ export type ServerSelectionRule =
340
+ | { selectAll: boolean; toggled: string[] }
341
+ | {
342
+ selectAllChildren: boolean
343
+ toggled: Record<string, { selectAllChildren: boolean; toggled: Record<string, unknown>; group?: boolean }>
344
+ group?: boolean
345
+ /** The group columns the tree's levels are keyed by, outer to inner. */
346
+ groupBy?: string[]
347
+ }
348
+
349
+ /**
350
+ * The contract a backend implements: `getRows`, and optionally the write
351
+ * methods, a bulk edit by rule and `destroy`.
352
+ */
175
353
  export type ServerDataSource<TData> = {
176
354
  getRows(request: ServerRequest): Promise<ServerResult<TData>>
177
355
  /**
@@ -183,8 +361,33 @@ export type ServerDataSource<TData> = {
183
361
  createRow?(input: Partial<TData>): Promise<TData>
184
362
  updateRow?(id: string, patch: Partial<TData>): Promise<TData>
185
363
  deleteRow?(id: string): Promise<void>
364
+ /**
365
+ * Apply one patch to every row a selection RULE names, server-side - the
366
+ * write behind a bulk edit of rows the grid never loaded. `filterModel`
367
+ * scopes the rows exactly as `getRows` does; `selection` is the rule:
368
+ * either "these ids" (`selectAll: false`) or "everything but these"
369
+ * (`selectAll: true`), or the per-group tree the Enterprise row model
370
+ * keeps under `groupSelects: 'descendants'`. Resolve with how many rows
371
+ * changed. Optional: without it, a bulk edit under select-all is refused
372
+ * rather than silently applied to the loaded rows only.
373
+ */
374
+ updateWhere?(
375
+ filterModel: ServerFilterModel,
376
+ patch: Partial<TData>,
377
+ selection: ServerSelectionRule,
378
+ ): Promise<number>
379
+ /**
380
+ * Called once, from the controller's `dispose()`, for a source with
381
+ * something to close: a socket, a subscription, a worker.
382
+ */
383
+ destroy?(): void
186
384
  }
187
385
 
386
+ /**
387
+ * What `createServerDataSource` emits on every change: the rows on hand,
388
+ * the counts, the loading and saving flags, the last error, and the
389
+ * current sort and filter.
390
+ */
188
391
  export type ServerState<TData> = {
189
392
  rows: ReadonlyArray<TData>
190
393
  total: number
@@ -206,13 +409,51 @@ export type ServerState<TData> = {
206
409
  * compiling; the controller always sets it.
207
410
  */
208
411
  expressionUnapplied?: boolean
412
+ /**
413
+ * Rows after filtering, or `null` when the backend has not said and the
414
+ * end has not been found yet. Only ever null in `infinite` mode; `total`
415
+ * carries the best current guess either way.
416
+ */
417
+ rowCount?: number | null
418
+ /** False while the end of an infinite list is still being discovered. */
419
+ lastRowKnown?: boolean
420
+ /** Blocks whose fetch failed, in `infinite` mode. Empty when all is well. */
421
+ failedBlocks?: number[]
209
422
  }
210
423
 
211
- export type ServerController<TData> = {
424
+ /**
425
+ * Everything a controller can do, plus the {@link GridRowModel} surface, so
426
+ * one object can be driven by hand OR handed to `<SvGrid rowModel>` and wire
427
+ * itself up.
428
+ */
429
+ export type ServerController<TData> = GridRowModel<TData> & {
212
430
  /** Re-fetch the current page (e.g. after a mutation). */
213
431
  refresh(): void
432
+ /**
433
+ * The rows on screen, so `infinite` mode knows which blocks to fetch and
434
+ * which to spare from eviction. Wire it to the grid's
435
+ * `onVisibleRangeChange` - or pass the controller as `rowModel` and the
436
+ * grid wires it for you. No-op in `page` mode.
437
+ */
438
+ setViewport(startIndex: number, endIndex: number): void
439
+ /** Re-fetch the blocks that failed. No-op in `page` mode. */
440
+ retryLoads(): void
441
+ /**
442
+ * Throw away every cached block and reload from the current viewport.
443
+ * `refresh()` is the gentler option: it keeps the row count and the scroll
444
+ * position. No-op in `page` mode, where there is only ever one page held.
445
+ */
446
+ purge(): void
447
+ /** Cached blocks and their state, for logging and tests. Empty in `page` mode. */
448
+ getCacheState(): BlockState[]
214
449
  setSort(sortModel: ServerSortModel): void
215
- setFilter(filterModel: ServerFilterModel): void
450
+ /**
451
+ * Replace the filter. Takes the `ServerFilterModel` a request carries, or
452
+ * the payload the grid's own `onFiltersChange` hands you - which is a
453
+ * list of columns rather than a map, and is converted here so that every
454
+ * app does not write the same `Object.fromEntries` by hand.
455
+ */
456
+ setFilter(filterModel: ServerFilterModel | GridFilterState): void
216
457
  setPage(pageIndex: number): void
217
458
  setPageSize(pageSize: number): void
218
459
  /**
@@ -237,10 +478,14 @@ export type ServerController<TData> = {
237
478
  dispose(): void
238
479
  }
239
480
 
481
+ /**
482
+ * Options for `createServerDataSource`: the page size, or the block
483
+ * settings in `infinite` mode, optimistic writes and the change callback.
484
+ */
240
485
  export type ServerControllerOptions<TData> = {
241
486
  pageSize?: number
242
487
  /** Called whenever any of `rows` / `total` / `loading` / page changes. */
243
- onChange: (state: ServerState<TData>) => void
488
+ onChange?: (state: ServerState<TData>) => void
244
489
  /**
245
490
  * Apply `updateRow` / `deleteRow` to the local rows immediately (before the
246
491
  * server confirms) and roll back on error - so edits feel instant and no
@@ -250,8 +495,40 @@ export type ServerControllerOptions<TData> = {
250
495
  optimistic?: boolean
251
496
  /** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */
252
497
  getRowId?: (row: TData) => string
498
+ /**
499
+ * How rows reach the grid.
500
+ *
501
+ * - `page` (default): one page at a time. `state.rows` is that page, and
502
+ * `setPage` moves between them.
503
+ * - `infinite`: one long scrollable list. `state.rows` spans the whole
504
+ * result, with placeholder rows standing in for blocks nobody has
505
+ * scrolled to yet; blocks load as the viewport reaches them.
506
+ */
507
+ mode?: 'page' | 'infinite'
508
+ /** `infinite` mode: rows per request. Default 100. */
509
+ blockSize?: number
510
+ /**
511
+ * `infinite` mode: keep at most this many loaded blocks, evicting the
512
+ * least recently seen. Unlimited by default.
513
+ */
514
+ maxBlocksInCache?: number
515
+ /** `infinite` mode: requests open at once. Default 2. */
516
+ maxConcurrentRequests?: number
517
+ /** `infinite` mode: wait for the scroll to settle this long before fetching. */
518
+ blockLoadDebounceMs?: number
519
+ /**
520
+ * `infinite` mode: rows to claim before anything has loaded, so there is a
521
+ * scrollbar on first paint. Default 1.
522
+ */
523
+ initialRowCount?: number
253
524
  }
254
525
 
526
+ /**
527
+ * The free server row model over a `ServerDataSource`: one page at a time,
528
+ * or one block-cached list in `infinite` mode, with sort, filter, race
529
+ * safety and writes. The result is a `GridRowModel`, so
530
+ * `<SvGrid rowModel={ctl} />` wires every seam.
531
+ */
255
532
  export function createServerDataSource<TData>(
256
533
  source: ServerDataSource<TData>,
257
534
  options: ServerControllerOptions<TData>,
@@ -270,9 +547,19 @@ export function createServerDataSource<TData>(
270
547
  expressionUnapplied: false,
271
548
  }
272
549
 
550
+ const infinite = options.mode === 'infinite'
551
+ // `onChange` is the one-callback API this controller shipped with;
552
+ // `subscribe` is the many-listener one `GridRowModel` needs. Both fire
553
+ // from `emit`, so a grid driven by `rowModel` and an app reading
554
+ // `onChange` stay in step.
555
+ const subscribers = new Set<() => void>()
556
+ let cache: BlockCache<TData> | null = null
557
+
273
558
  // Monotonic request id: only the latest fetch is allowed to land, so a slow
274
559
  // response for an old sort/filter can't clobber a newer one.
275
560
  let requestSeq = 0
561
+ /** The page fetch in flight; a newer one, or dispose, aborts it. */
562
+ let pageAbort: AbortController | null = null
276
563
  let disposed = false
277
564
  // Once per controller: a misconfigured backend would otherwise log on every
278
565
  // page, scroll and filter change.
@@ -280,12 +567,97 @@ export function createServerDataSource<TData>(
280
567
 
281
568
  const emit = () => {
282
569
  state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))
283
- options.onChange({ ...state })
570
+ options.onChange?.({ ...state })
571
+ for (const notify of subscribers) notify()
572
+ }
573
+
574
+ /**
575
+ * Pull the block cache's view of the world into `state` and emit.
576
+ *
577
+ * `total` stays a plain number because the pager and the footer have
578
+ * always read it as one; while the end is undiscovered it holds the
579
+ * current optimistic length, which is exactly what the scrollbar needs.
580
+ * `rowCount` is the honest answer, null and all.
581
+ */
582
+ function emitFromCache(): void {
583
+ if (!cache || disposed) return
584
+ const rows = cache.rows()
585
+ state.rows = rows
586
+ state.rowCount = cache.rowCount()
587
+ state.lastRowKnown = cache.lastRowKnown()
588
+ state.total = cache.rowCount() ?? rows.length
589
+ emit()
590
+ }
591
+
592
+ function buildCache(): BlockCache<TData> {
593
+ return createBlockCache<TData>({
594
+ blockSize: options.blockSize ?? 100,
595
+ maxBlocksInCache: options.maxBlocksInCache,
596
+ maxConcurrentRequests: options.maxConcurrentRequests,
597
+ blockLoadDebounceMs: options.blockLoadDebounceMs,
598
+ initialRowCount: options.initialRowCount,
599
+ fetch: async (startRow, endRow, signal) => {
600
+ const result = await source.getRows({
601
+ startRow,
602
+ endRow,
603
+ // A block is a page of its own size, so a backend that only knows
604
+ // how to page still works unchanged.
605
+ pageIndex: Math.floor(startRow / Math.max(1, endRow - startRow)),
606
+ pageSize: endRow - startRow,
607
+ sortModel: state.sortModel,
608
+ filterModel: state.filterModel,
609
+ groupBy: [],
610
+ groupKeys: [],
611
+ aggregations: [],
612
+ signal,
613
+ })
614
+ noteExpressionApplied(result)
615
+ return { rows: result.rows, rowCount: result.rowCount }
616
+ },
617
+ onChange: (s) => {
618
+ state.loading = s.loading
619
+ state.failedBlocks = s.failedBlocks
620
+ emitFromCache()
621
+ },
622
+ })
623
+ }
624
+
625
+ /** Reload from scratch: new sort, new filter, or an explicit purge. */
626
+ function resetCache(): void {
627
+ if (disposed) return
628
+ cache?.dispose()
629
+ cache = buildCache()
630
+ cache.setViewport(viewStart, viewEnd)
631
+ emitFromCache()
632
+ }
633
+
634
+ /**
635
+ * An expression was sent but the backend did not acknowledge applying it,
636
+ * so these rows are a SUPERSET of what was asked for. Say so instead of
637
+ * filtering here: filtering one page would turn "3 of 1,000,000 match"
638
+ * into a confident lie and make paging incoherent, since the next page
639
+ * would re-filter a different slice.
640
+ */
641
+ function noteExpressionApplied(result: ServerResult<TData>): void {
642
+ state.expressionUnapplied =
643
+ state.filterModel.expression != null && result.appliedExpression !== true
644
+ if (!state.expressionUnapplied || warnedExpressionUnapplied) return
645
+ warnedExpressionUnapplied = true
646
+ console.warn(
647
+ '[svgrid] The data source was sent filterModel.expression but did not ' +
648
+ 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
649
+ 'and the rows shown are unfiltered. Apply the whole expression and ' +
650
+ 'acknowledge it, or clear the advanced filter. ' +
651
+ 'See https://svgrid.com/docs/help/server/server-filtering',
652
+ )
284
653
  }
285
654
 
286
655
  async function fetchPage() {
287
656
  if (disposed) return
288
657
  const id = ++requestSeq
658
+ pageAbort?.abort()
659
+ const controller = new AbortController()
660
+ pageAbort = controller
289
661
  state.loading = true
290
662
  state.error = null
291
663
  emit()
@@ -298,31 +670,19 @@ export function createServerDataSource<TData>(
298
670
  pageSize: state.pageSize,
299
671
  sortModel: state.sortModel,
300
672
  filterModel: state.filterModel,
301
- // Flat mode: no grouping. Group mode lives in createServerGroupModel.
673
+ // Flat mode: no grouping. Server-side grouping and tree data live in
674
+ // `createServerGroupModel` from @svgrid/enterprise, which fills these in.
302
675
  groupBy: [],
303
676
  groupKeys: [],
304
677
  aggregations: [],
678
+ signal: controller.signal,
305
679
  })
306
680
  if (disposed || id !== requestSeq) return // stale
307
681
  state.rows = result.rows
308
682
  state.total = result.rowCount
309
- // An expression was sent but the backend did not acknowledge applying it,
310
- // so these rows are a SUPERSET of what was asked for. Say so instead of
311
- // filtering the loaded page here: filtering one page would turn
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
- }
683
+ state.rowCount = result.rowCount
684
+ state.lastRowKnown = true
685
+ noteExpressionApplied(result)
326
686
  state.loading = false
327
687
  emit()
328
688
  } catch (err) {
@@ -351,7 +711,10 @@ export function createServerDataSource<TData>(
351
711
  return (async () => {
352
712
  try {
353
713
  const result = await thunk()
354
- await fetchPage()
714
+ // Page mode re-reads the page. Infinite mode re-reads the blocks it
715
+ // holds, in place: the list keeps its length and its scroll.
716
+ if (infinite) cache?.refresh()
717
+ else await fetchPage()
355
718
  return result
356
719
  } finally {
357
720
  state.saving = false
@@ -363,6 +726,11 @@ export function createServerDataSource<TData>(
363
726
  const optimistic = !!options.optimistic && !!options.getRowId
364
727
  const getRowId = options.getRowId
365
728
 
729
+ // The last range the grid reported, so a sort / filter / purge can
730
+ // re-request what the user is actually looking at rather than row 0.
731
+ let viewStart = 0
732
+ let viewEnd = 0
733
+
366
734
  // Optimistic update: patch the local row, reconcile with the server result,
367
735
  // roll back on error. Falls back to the plain refresh path when the row
368
736
  // isn't on the current page (nothing local to update).
@@ -372,6 +740,27 @@ export function createServerDataSource<TData>(
372
740
  fn: (id: string, patch: Partial<TData>) => Promise<TData>,
373
741
  ): Promise<TData> {
374
742
  if (disposed) throw new Error('createServerDataSource: controller is disposed')
743
+ if (infinite && cache) {
744
+ // The cache owns the rows here; patch it, not a copy the next block
745
+ // to land would overwrite.
746
+ const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)
747
+ if (at < 0) return mutate('updateRow', () => fn(id, patch))
748
+ const prev = cache.getRow(at) as TData
749
+ cache.patch(at, { ...prev, ...patch })
750
+ state.saving = true
751
+ emit()
752
+ try {
753
+ const result = await fn(id, patch)
754
+ cache.patch(at, result)
755
+ return result
756
+ } catch (err) {
757
+ cache.patch(at, prev)
758
+ throw err
759
+ } finally {
760
+ state.saving = false
761
+ emit()
762
+ }
763
+ }
375
764
  const prevRows = state.rows
376
765
  const idx = prevRows.findIndex((r) => getRowId!(r) === id)
377
766
  if (idx < 0) return mutate('updateRow', () => fn(id, patch))
@@ -398,6 +787,24 @@ export function createServerDataSource<TData>(
398
787
  fn: (id: string) => Promise<void>,
399
788
  ): Promise<void> {
400
789
  if (disposed) throw new Error('createServerDataSource: controller is disposed')
790
+ if (infinite && cache) {
791
+ const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)
792
+ if (at < 0) return mutate('deleteRow', () => fn(id))
793
+ const prev = cache.getRow(at) as TData
794
+ cache.remove(at, 1)
795
+ state.saving = true
796
+ emit()
797
+ try {
798
+ await fn(id)
799
+ } catch (err) {
800
+ cache.insert(at, [prev])
801
+ throw err
802
+ } finally {
803
+ state.saving = false
804
+ emit()
805
+ }
806
+ return
807
+ }
401
808
  const prevRows = state.rows
402
809
  const prevTotal = state.total
403
810
  const next = prevRows.filter((r) => getRowId!(r) !== id)
@@ -419,8 +826,53 @@ export function createServerDataSource<TData>(
419
826
  }
420
827
  }
421
828
 
829
+ // In infinite mode the first blocks are requested as soon as the grid
830
+ // reports a viewport; `refresh()` is what arms the cache before that.
831
+ if (infinite) cache = buildCache()
832
+
422
833
  return {
423
- refresh: fetchPage,
834
+ refresh: () => {
835
+ if (!infinite) return void fetchPage()
836
+ // Keep the count and the scroll position, re-request what is held.
837
+ cache?.refresh()
838
+ },
839
+ setViewport(startIndex, endIndex) {
840
+ viewStart = startIndex
841
+ viewEnd = endIndex
842
+ cache?.setViewport(startIndex, endIndex)
843
+ },
844
+ retryLoads: () => cache?.retryFailed(),
845
+ purge: () => cache?.purge(),
846
+ getCacheState: () => cache?.getCacheState() ?? [],
847
+
848
+ // --- GridRowModel -----------------------------------------------
849
+ subscribe(onChange) {
850
+ subscribers.add(onChange)
851
+ return () => {
852
+ subscribers.delete(onChange)
853
+ }
854
+ },
855
+ getRows: () => state.rows,
856
+ isLoading: () => state.loading,
857
+ getRowId: options.getRowId ? (row: TData) => options.getRowId!(row) : undefined,
858
+ // Only infinite mode has unloaded rows to stand in for.
859
+ rowPlaceholder: infinite ? (row: TData) => rowPlaceholderState(row) : undefined,
860
+ retryRow: infinite ? (_row: TData, rowIndex: number) => cache?.retryFailedAt(rowIndex) : undefined,
861
+ /**
862
+ * A getter, not a snapshot: the grid re-reads this after every change
863
+ * notification, and a frozen object would leave the pager on page 1
864
+ * forever. Absent in infinite mode, where there are no pages.
865
+ */
866
+ get pagination() {
867
+ if (infinite) return undefined
868
+ return {
869
+ pageIndex: state.pageIndex,
870
+ pageSize: state.pageSize,
871
+ rowCount: state.total,
872
+ setPage: (pageIndex: number) => this.setPage(pageIndex),
873
+ setPageSize: (pageSize: number) => this.setPageSize(pageSize),
874
+ }
875
+ },
424
876
  createRow: (input) =>
425
877
  mutate('createRow', source.createRow ? () => source.createRow!(input) : null),
426
878
  updateRow: (id, patch) => {
@@ -436,14 +888,24 @@ export function createServerDataSource<TData>(
436
888
  setSort(sortModel) {
437
889
  state.sortModel = sortModel
438
890
  state.pageIndex = 0
891
+ if (infinite) return resetCache()
439
892
  void fetchPage()
440
893
  },
441
894
  setFilter(filterModel) {
442
- state.filterModel = filterModel
895
+ state.filterModel = Array.isArray(filterModel.columns)
896
+ ? {
897
+ global: filterModel.global,
898
+ columns: toServerFilterColumns(filterModel as GridFilterState),
899
+ }
900
+ : (filterModel as ServerFilterModel)
443
901
  state.pageIndex = 0
902
+ if (infinite) return resetCache()
444
903
  void fetchPage()
445
904
  },
446
905
  setPage(pageIndex) {
906
+ // There are no pages to move between when the whole result is one
907
+ // scrollable list; the grid scrolls instead.
908
+ if (infinite) return
447
909
  const clamped = Math.max(0, pageIndex)
448
910
  if (clamped === state.pageIndex) return
449
911
  state.pageIndex = clamped
@@ -452,11 +914,16 @@ export function createServerDataSource<TData>(
452
914
  setPageSize(pageSize) {
453
915
  state.pageSize = Math.max(1, pageSize)
454
916
  state.pageIndex = 0
917
+ if (infinite) return
455
918
  void fetchPage()
456
919
  },
457
920
  getState: () => ({ ...state }),
458
921
  dispose() {
459
922
  disposed = true
923
+ pageAbort?.abort()
924
+ pageAbort = null
925
+ cache?.dispose()
926
+ cache = null
460
927
  // An in-flight fetch's resolution short-circuits on `disposed`, so it
461
928
  // never clears `loading`. Clear it here (and emit) so a disposed
462
929
  // controller doesn't report a permanent loading state.