@jielga/tmdatagrid 2.0.0-beta.9 → 2.0.1

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 (157) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1281 -768
  3. package/dist/index.js +4607 -3250
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/adding-rows.md +132 -0
  7. package/docs/anatomy.md +119 -0
  8. package/docs/card-view.md +108 -0
  9. package/docs/cell-selection.md +194 -0
  10. package/docs/column-layout.md +182 -0
  11. package/docs/column-menu.md +66 -0
  12. package/docs/columns.md +269 -0
  13. package/docs/components.md +311 -0
  14. package/docs/draft-store.md +242 -0
  15. package/docs/editing.md +303 -0
  16. package/docs/editors.md +250 -0
  17. package/docs/export.md +319 -0
  18. package/docs/filtering.md +362 -0
  19. package/docs/getting-started.md +123 -0
  20. package/docs/grouping.md +165 -0
  21. package/docs/loading-and-empty.md +92 -0
  22. package/docs/localization.md +79 -0
  23. package/docs/menu.md +143 -0
  24. package/docs/migrating-to-2.md +163 -0
  25. package/docs/pagination.md +144 -0
  26. package/docs/persistence.md +114 -0
  27. package/docs/portfolio-rebalancer.md +94 -0
  28. package/docs/query-builder.md +179 -0
  29. package/docs/quick-search.md +84 -0
  30. package/docs/row-details.md +115 -0
  31. package/docs/row-interaction.md +149 -0
  32. package/docs/row-pinning.md +132 -0
  33. package/docs/row-selection.md +136 -0
  34. package/docs/row-styling.md +133 -0
  35. package/docs/scrolling.md +112 -0
  36. package/docs/server-query.md +246 -0
  37. package/docs/server-side.md +206 -0
  38. package/docs/sorting.md +101 -0
  39. package/docs/styling.md +126 -0
  40. package/docs/summary-row.md +76 -0
  41. package/docs/testing.md +744 -0
  42. package/docs/toolbar.md +161 -0
  43. package/docs/use-tm-data-grid.md +361 -0
  44. package/package.json +22 -46
  45. package/skills/appearance/SKILL.md +72 -19
  46. package/skills/cell-selection/SKILL.md +49 -48
  47. package/skills/columns/SKILL.md +125 -70
  48. package/skills/columns/references/columns-api.md +59 -0
  49. package/skills/data/SKILL.md +112 -18
  50. package/skills/editing/SKILL.md +76 -42
  51. package/skills/editing/references/common-mistakes.md +77 -69
  52. package/skills/editing/references/editing-api.md +25 -20
  53. package/skills/editing/references/editors-and-validation.md +80 -18
  54. package/skills/filtering/SKILL.md +155 -41
  55. package/skills/getting-started/SKILL.md +116 -16
  56. package/skills/grouping/SKILL.md +31 -16
  57. package/skills/migrating-to-2/SKILL.md +244 -0
  58. package/skills/options/SKILL.md +24 -12
  59. package/skills/rows/SKILL.md +22 -18
  60. package/skills/rows/references/rows-api.md +10 -6
  61. package/skills/server-side/SKILL.md +170 -17
  62. package/skills/testing/SKILL.md +150 -32
  63. package/skills/testing-components/SKILL.md +230 -0
  64. package/skills/testing-editing/SKILL.md +240 -0
  65. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  66. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +54 -8
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  68. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +5 -51
  70. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  71. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +12 -59
  72. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +164 -92
  73. package/src/components/TMDataGridExportPicker.module.css +77 -0
  74. package/src/components/TMDataGridExportPicker.tsx +234 -0
  75. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  76. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  77. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  78. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  79. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  80. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  81. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +5 -69
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  83. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  84. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  85. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  86. package/src/components/TMDataGridMenu.tsx +357 -0
  87. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +11 -48
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +69 -56
  89. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +240 -138
  90. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  91. package/src/components/TMDataGridToolbar.tsx +181 -0
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  98. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  99. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  100. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  101. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  103. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  104. package/src/components/filters/controlLayout.ts +32 -0
  105. package/src/components/filters/filterControlFor.ts +65 -0
  106. package/src/components/generatedColumns.tsx +187 -0
  107. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  108. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  109. package/src/components/useHideableColumns.ts +52 -0
  110. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  111. package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
  112. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  113. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  114. package/src/core/controlledStateSync.ts +108 -0
  115. package/src/core/deletedRows.ts +34 -0
  116. package/src/core/dom.ts +74 -0
  117. package/src/{tmdatagrid/core → core}/editEngine.ts +1107 -460
  118. package/src/core/export.ts +704 -0
  119. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  120. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  121. package/src/core/filterSurface.ts +99 -0
  122. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  123. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  124. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  125. package/src/core/pageReset.ts +120 -0
  126. package/src/core/pagination.ts +81 -0
  127. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  128. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  129. package/src/{tmdatagrid/index.ts → index.ts} +69 -35
  130. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +428 -109
  131. package/src/useTMDataGridExport.ts +78 -0
  132. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  133. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  134. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  135. package/src/tmdatagrid/core/cellExport.ts +0 -320
  136. /package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +0 -0
  138. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  141. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  142. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  143. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  144. /package/src/{tmdatagrid/core → core}/capabilities.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/editorFocus.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  155. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  156. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  157. /package/src/{tmdatagrid/core → core}/useSettledTableState.ts +0 -0
@@ -4,16 +4,22 @@ description: >
4
4
  Drive TMDataGrid from a server with TanStack manual modes - manualPagination,
5
5
  manualSorting, manualFiltering, rowCount, controlled state and onXChange
6
6
  callbacks. Covers the loading and totalRowCount meta fields, forwarding the
7
- plain-JSON columnFilters model to an API with isFilterActive, persistence
8
- interaction, and row selection across pages. Load when the grid is backed by a
9
- paginated API rather than a local array.
7
+ plain-JSON columnFilters model to an API with activeColumnFilters, mapping
8
+ filters, sorting and the page index onto an endpoint's own query language
9
+ (field table, operator table, the three value shapes, meta.filter.operators
10
+ for an endpoint that answers only some operators, keying the fetch on the
11
+ request, paging against a page envelope), the first-page reset on a query
12
+ change, persistence interaction, and row selection across pages. Load when the
13
+ grid is backed by a paginated API rather than a local array, or when
14
+ translating grid filters into server-side queries.
10
15
  metadata:
11
16
  type: core
12
17
  library: '@jielga/tmdatagrid'
13
- library_version: '2.0.0-beta.9'
18
+ library_version: '2.0.1'
14
19
  sources:
15
- - 'Jielga/TMDataGrid:src/docs/server-side.md'
16
- - 'Jielga/TMDataGrid:src/tmdatagrid/useTMDataGrid.tsx'
20
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/server-side.md'
21
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/server-query.md'
22
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/src/useTMDataGrid.tsx'
17
23
  ---
18
24
 
19
25
  # TMDataGrid - Server-side data
@@ -68,12 +74,32 @@ const grid = useTMDataGrid({
68
74
  | --- | --- | --- |
69
75
  | Rendered rows | The current page, sliced locally | The rows returned by the server |
70
76
  | Footer total | Pre-paginated row count | `options.rowCount` |
71
- | `SummaryCount` total | Pre-filtered row count | `meta.totalRowCount` |
77
+ | `SummaryCount` total | Pre-filtered row count | `meta.totalRowCount`; the count alone without it |
72
78
  | Loading state | Not applicable | `meta.loading` |
73
79
 
74
80
  Column menus, the filter panel and the column manager behave identically in both
75
81
  modes.
76
82
 
83
+ ## The page index
84
+
85
+ Under `manualPagination` the grid resets `pageIndex` to 0 whenever the query
86
+ changes - a column filter, the quick search or the sort - so the next request
87
+ never asks for page 8 of a result set that now has three. The reset lands in
88
+ the same event as the change, so one request goes out. Pass the plain setters
89
+ as the change callbacks; do not pair them with a page reset of your own.
90
+
91
+ `resetPageOnQueryChange: false` switches it off. TanStack's
92
+ `autoResetPageIndex` is a different rule: it defaults to `!manualPagination`
93
+ and fires on a change to `data`, which server-side is the response landing.
94
+
95
+ ## Column options
96
+
97
+ `meta.options: "faceted"` reads the distinct values in `data`, which
98
+ server-side is one page of them - the dropdown then offers whatever was on the
99
+ page the user is looking at. Declare the set as a list or a function instead.
100
+ The grid warns once per column when a faceted column resolves under
101
+ `manualFiltering` or `manualPagination`.
102
+
77
103
  ## Sending filters
78
104
 
79
105
  Filter values are plain JSON, so `columnFilters` forwards without transformation:
@@ -85,17 +111,136 @@ Filter values are plain JSON, so `columnFilters` forwards without transformation
85
111
  ]
86
112
  ```
87
113
 
88
- Translate at the API boundary, and skip entries whose value is still empty -
89
- those match all rows:
114
+ Translate at the API boundary. `activeColumnFilters` hands back the entries
115
+ that narrow anything, typed - `ColumnFiltersState` types `value` as `unknown`,
116
+ and an entry whose value is still empty matches all rows:
90
117
 
91
118
  ```ts
92
- import { isFilterActive } from "@jielga/tmdatagrid";
119
+ import { activeColumnFilters } from "@jielga/tmdatagrid";
93
120
 
94
- const active = columnFilters.filter((filter) => isFilterActive(filter.value));
121
+ const active = activeColumnFilters(columnFilters);
122
+ // [{ id: "lastName", value: { operator: "contains", value: "holm" } }]
95
123
  ```
96
124
 
125
+ It takes the `columnFilters` array, or the table where the grid owns the slice.
126
+ `isFilterActive(value)` is the single-value test it is built on. Only the
127
+ grid's own `{ operator, value }` shape is read: an entry holding some other
128
+ value - a custom filter control writing raw values - is dropped.
129
+
97
130
  Debounce requests. The filter value input updates on every keystroke.
98
131
 
132
+ ## Mapping onto the endpoint's query language
133
+
134
+ An API takes a request body of its own: its own field names, its own operator
135
+ set, its own status codes, and pages counted from 1. The layer between the
136
+ grid's state and that body is one function over two lookup tables, plus one
137
+ function on the way back:
138
+
139
+ - **A field table** keyed by column id, giving the API field and the cast from
140
+ the string every filter control writes to the type the field holds
141
+ (`Number`, an enum code). A column missing from the table is one the API
142
+ cannot query: the mapping drops the filter rather than sending a field the
143
+ endpoint would reject.
144
+ - **An operator table** from `TMDataGridFilterOperator` to the API's operators.
145
+ Several grid operators collapse onto one - a date `before` and a number
146
+ `lessThan` are both `lt` once the value is cast. Declare it as a `Record`, not
147
+ a `Partial`, so an operator added by a later grid version fails the build
148
+ here rather than reaching the server unmapped.
149
+ - **`toRow`** from the API's record to the grid's row type.
150
+
151
+ ```ts
152
+ const QUERY_FIELDS: Record<string, { field: string; cast: (raw: string) => string | number }> = {
153
+ id: { field: "orderRef", cast: Number },
154
+ amount: { field: "totalAmount", cast: Number },
155
+ status: { field: "status", cast: (raw) => STATUS_CODES[raw] ?? raw },
156
+ };
157
+
158
+ const PREDICATE_OPS: Record<TMDataGridFilterOperator, PredicateOp> = {
159
+ contains: "like",
160
+ between: "range",
161
+ before: "lt",
162
+ lessThan: "lt",
163
+ isAnyOf: "in",
164
+ isEmpty: "isNull",
165
+ // ...one line for every remaining operator.
166
+ };
167
+ ```
168
+
169
+ ### The three value shapes
170
+
171
+ `TMDataGridFilterValue` is `{ operator, value }`, and the operator decides what
172
+ `value` holds. Branch on all four cases, in this order:
173
+
174
+ | Operator | `value` | Sent as |
175
+ | --- | --- | --- |
176
+ | `isEmpty`, `isNotEmpty` | Not used | `{ field, op }` |
177
+ | `isAnyOf`, `isNoneOf` | `ReadonlyArray<string>` | `{ field, op, values }` |
178
+ | `between` | `[min, max]`, either end possibly `""` | `{ field, op, from?, to? }` |
179
+ | Everything else | `string` | `{ field, op, value }` |
180
+
181
+ An empty end of a `between` pair leaves that side open: an absent bound, not an
182
+ empty string. Run `activeColumnFilters` over the slice first so a half-typed
183
+ filter is not sent as a predicate that narrows the result to nothing.
184
+
185
+ ### An endpoint that answers only some operators
186
+
187
+ Most endpoints do not have every operator the grid has - `like` and `eq` but no
188
+ prefix match is common. Do not offer what you would have to drop.
189
+ `meta.filter.operators` narrows the column to the operators the query can
190
+ express, and the mapping table is declared over exactly that list, so one
191
+ cannot be offered without a mapping or mapped without being offered:
192
+
193
+ ```ts
194
+ const TEXT_OPERATORS = [
195
+ "contains",
196
+ "equals",
197
+ "isEmpty",
198
+ "isNotEmpty",
199
+ ] as const satisfies readonly TMDataGridFilterOperator[];
200
+
201
+ const TEXT_OPS: Record<(typeof TEXT_OPERATORS)[number], PredicateOp> = {
202
+ contains: "like",
203
+ equals: "eq",
204
+ isEmpty: "isNull",
205
+ isNotEmpty: "isNotNull",
206
+ };
207
+
208
+ columnHelper.accessor("customer", {
209
+ header: "Customer",
210
+ meta: { filter: { operators: TEXT_OPERATORS } },
211
+ });
212
+ ```
213
+
214
+ A fresh filter opens on `meta.filter.defaultOperator` when set, else on the
215
+ type's default when the list holds it, else on the first entry. The lookup at
216
+ the boundary still returns `undefined` for an unmapped operator: a filter
217
+ restored by `persist` from before the list was narrowed can carry one.
218
+
219
+ ### Keying the fetch on the request
220
+
221
+ Serialize the request with `JSON.stringify` inside `useMemo` over
222
+ `columnFilters`, `sorting` and `pagination`, and key the fetch effect (or the
223
+ TanStack Query `queryKey`) on that string. Opening the panel and adding an
224
+ empty row moves `columnFilters` but leaves the request unchanged, so nothing is
225
+ sent. The effect owes the server a debounce (the value input updates on every
226
+ keystroke) and a cancel (a `cancelled` flag in the cleanup, or an
227
+ `AbortController` on a real `fetch`).
228
+
229
+ ### Paging against a page envelope
230
+
231
+ | The API's | The grid's | Written as |
232
+ | --- | --- | --- |
233
+ | `page.number`, counted from 1 | `pagination.pageIndex`, counted from 0 | `number: pageIndex + 1` |
234
+ | `page.totalItems`, the matched count | `rowCount` | `rowCount: page?.totalItems ?? 0` |
235
+ | `page.totalPages` | `state.pageCount`, derived from `rowCount / pageSize` | Nothing; the grid computes it |
236
+
237
+ Forward `totalPages` only when the server pages by something other than the
238
+ size the grid asked for. `pageCount: -1` when the total is unknown. Show the
239
+ page number through the Footer's `renderPagination` slot with
240
+ `<Controls.PageSize /><Controls.PageNumber /><Controls.Pager />`.
241
+ `meta.totalRowCount` is the unfiltered total, which no filtered response
242
+ carries: take it from a separate count call.
243
+
99
244
  ## Persistence
100
245
 
101
246
  `persist` works unchanged. `dataKey` restores filters, sorting and pagination
@@ -126,18 +271,26 @@ Without `rowCount` the table derives the total from the rows it was handed -
126
271
  one page - so `getPageCount()` returns 1. The footer shows "1–25 of 25" and the
127
272
  next-page button is disabled, with no error. Pass the server total.
128
273
 
129
- ### Filters sent without isFilterActive
274
+ ### Filters sent without activeColumnFilters
130
275
 
131
276
  An empty filter value stays in `columnFilters` while the user is still typing.
132
277
  Forwarded verbatim it becomes `operator: "contains", value: ""` at the API,
133
- which most backends translate into a real predicate. Filter with
134
- `isFilterActive` first.
278
+ which most backends translate into a real predicate. Map over
279
+ `activeColumnFilters(columnFilters)` instead of over the slice.
135
280
 
136
281
  ### SummaryCount without meta.totalRowCount
137
282
 
138
- `SummaryCount` falls back to the pre-filtered row count, which under manual
139
- filtering is just the current page. It reads "25 of 25" regardless of how many
140
- rows the server holds. Pass the unfiltered total as `meta.totalRowCount`.
283
+ Without it there is no denominator to show: the pre-filtered row count is the
284
+ current page, so the grid renders the matched count alone rather than a
285
+ plausible-looking wrong total. Pass the unfiltered total as
286
+ `meta.totalRowCount` to get the "42 / 5000" form back.
287
+
288
+ ### Offering operators the endpoint cannot answer
289
+
290
+ The panel offers every operator of the column's type, and a mapping that drops
291
+ `startsWith` leaves the user with a filter that silently does nothing. Declare
292
+ `meta.filter.operators` on the column with the operators the endpoint answers,
293
+ and type the operator table over that same list.
141
294
 
142
295
  ### Unstable getRowId across pages
143
296
 
@@ -4,18 +4,21 @@ description: >
4
4
  Write tests against TMDataGrid from a consuming app - Playwright or React
5
5
  Testing Library. Covers the data-dg-part contract, data-row-id/data-column-id
6
6
  coordinates, naming a grid with data-testid, the roles and ARIA the grid
7
- publishes, the cell/gridcell role flip under cell selection, reaching rows
8
- past virtualization with data-dg-row-count and scrollToRow, and waiting on
9
- aria-busy. Load when writing or fixing tests that drive a grid, or when a
10
- selector for a row, cell or control does not resolve.
7
+ publishes, the cell/gridcell role flip under cell selection, the surfaces that
8
+ render in a portal (the menu, the export picker, Select listboxes), reaching
9
+ rows past virtualization with data-dg-row-count, scrollToRow and
10
+ data-dg-scroll-container, waiting on aria-busy, and the DataGrid page object.
11
+ Load when writing or fixing tests that drive a grid, or when a selector for a
12
+ row, cell or control does not resolve.
11
13
  metadata:
12
14
  type: core
13
15
  library: '@jielga/tmdatagrid'
14
- library_version: '2.0.0-beta.9'
16
+ library_version: '2.0.1'
15
17
  sources:
16
- - 'Jielga/TMDataGrid:src/docs/testing.md'
17
- - 'Jielga/TMDataGrid:src/tmdatagrid/components/TMDataGrid.tsx'
18
- - 'Jielga/TMDataGrid:src/tmdatagrid/components/TMDataGridTable.tsx'
18
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/testing.md'
19
+ - 'Jielga/TMDataGrid:playwright/support/DataGrid.ts'
20
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/src/components/TMDataGrid.tsx'
21
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/src/components/TMDataGridTable.tsx'
19
22
  ---
20
23
 
21
24
  # TMDataGrid - Testing
@@ -33,6 +36,11 @@ The grid mints no `data-testid` of its own - that attribute belongs to the app,
33
36
  and Playwright's `testIdAttribute` is configurable. `@mantine/core` and
34
37
  `@tanstack/*` ship none either.
35
38
 
39
+ Adding, changing and deleting rows, the draft store, and where a new row's
40
+ temporary id goes at commit are in the `testing-editing` skill. Running the grid
41
+ in a real browser through Playwright's `mount` fixture - virtualization, resize,
42
+ drag, pinning, clipboard - is in the `testing-components` skill.
43
+
36
44
  ## Naming a grid
37
45
 
38
46
  Parts repeat across grids on a page. Name the grid, scope through it:
@@ -52,70 +60,134 @@ accessible name belongs to the element carrying the `grid` role.
52
60
  | Element | Role | Key attributes |
53
61
  | --- | --- | --- |
54
62
  | Grid | `table`, or `grid` under cell selection | `aria-rowcount`, `aria-colcount`, `aria-busy`, `data-dg-row-count` |
63
+ | Scroll container | - | `data-dg-scroll-container` - the element to scroll |
55
64
  | Body row | `row` | `data-row-id`, `aria-rowindex`, `data-selected`, `data-highlighted`, `data-grouped`, `data-pinned`, `data-deleted` |
56
65
  | Body cell | `cell`, or `gridcell` under cell selection | `data-row-id`, `data-column-id`, `data-editing`, `data-dirty`, `data-invalid`, `data-focused` |
57
66
  | Header cell | `columnheader` | `data-column-id`, `aria-sort` |
58
67
 
59
68
  Body cells carry no `data-dg-part` - the coordinate pair already names them.
69
+ The `editor` part of an open cell carries the same pair, so a cell locator is
70
+ `[data-row-id="42"][data-column-id="total"]:not([data-dg-part])`; without the
71
+ exclusion it matches two elements while the cell is being edited.
72
+
73
+ State attributes are present only while they apply: `data-selected`,
74
+ `data-new`, `data-invalid` and the rest are rendered as `"true"` while the
75
+ state holds and omitted otherwise, so `[data-new]` and `[data-new="true"]`
76
+ match the same rows, and the negative is `:not([data-new])` in CSS and
77
+ `not.toHaveAttribute("data-new")` in a test.
60
78
 
61
79
  ## Parts
62
80
 
63
81
  **Whole-grid** (unique, no coordinate needed): `toolbar`, `summary-count`,
64
82
  `loading`, `search`, `search-clear`, `filter-button`, `filter-panel`,
65
- `filter-panel-close`, `filter-add`, `filter-clear-all`, `filter-pills`,
66
- `columns-button`, `columns-panel`, `columns-search`, `columns-toggle-all`,
67
- `columns-reset`, `footer`, `page-size`, `page-range`, `page-prev`, `page-next`,
68
- `summary-row`, `pinned-top`, `pinned-bottom`, `select-all`,
69
- `details-toggle-all`, `save-all`, `discard-all`, `editor-confirm`,
70
- `editor-cancel`, `editor-input`, `sort-index`.
83
+ `filter-popup`, `filter-sidebar`, `filter-panel-close`, `filter-add`,
84
+ `filter-clear-all`, `filter-pills`, `header-filter-row`,
85
+ `menu-button`, `menu-export`, `menu-export-selected`, `export-picker`,
86
+ `export-picker-search`, `export-picker-hint`, `export-picker-count`,
87
+ `export-column-all`, `export-picker-confirm`, `export-picker-cancel`,
88
+ `columns-panel`, `columns-search`, `columns-toggle-all`,
89
+ `columns-reset`, `footer`, `page-size`, `page-range`, `page-number`,
90
+ `page-prev`, `page-next`, `summary-row`, `pinned-top`, `pinned-bottom`,
91
+ `select-all`, `details-toggle-all`, `save-all`, `discard-all`,
92
+ `editor-confirm`, `editor-cancel`, `editor-input`, `sort-index`, `tab-guard`.
71
93
 
72
94
  **Keyed by `data-row-id`**: `row`, `entry-row`, `details`, `select-row`,
73
95
  `details-toggle`, `group-toggle`, `edit-row`, `delete-row`, `save-row`,
74
96
  `cancel-row`, `row-state`, `revert-row`, `restore-row`, `confirm-new-row`,
75
- `discard-new-row`.
97
+ `discard-new-row`, `open-rows-note`.
76
98
 
77
99
  **Keyed by `data-column-id`**: `header`, `header-sort`, `header-menu`,
78
- `header-filter`, `filter-row`, `filter-pill`, `columns-toggle`.
100
+ `header-resize` (the resize handle; present only when the column can resize),
101
+ `header-filter` (absent under `filters.inHeader`), `header-filter-cell`,
102
+ `header-filter-operator`, `filter-row`, `filter-pill`, `columns-toggle`,
103
+ `export-column`.
79
104
 
80
105
  **Keyed by both**: `editor`.
81
106
 
82
107
  Inside a `filter-row` the controls are `filter-column`, `filter-operator` and
83
- `filter-value` - or `filter-value-from` / `filter-value-to` for `between`.
108
+ `filter-value` - or `filter-value-from` / `filter-value-to` for `between` - and
109
+ `filter-remove` is its ✕. None of them carries `data-column-id`; scope through
110
+ the row. Inside a `filter-pill`, `filter-pill-remove` is the ✕.
111
+ `TMDataGridFilterPills` renders where you place it; outside the root, scope
112
+ through its own container rather than through the grid.
84
113
 
85
114
  A column declaring `meta.filter.control` or `meta.edit.editor` renders your own
86
115
  component in that slot, so `filter-value` and `editor-input` cover the built-ins
87
116
  only. `filter-row` and `editor` still hold; scope through them.
88
117
 
118
+ Sorting: click `header`. `header-sort` is hidden until the header is hovered;
119
+ it shows the state, and `aria-sort` on the header is the assertion.
120
+
121
+ ## Portals
122
+
123
+ These surfaces render at the end of `<body>`, outside the grid's root, so a
124
+ locator scoped to the root never finds them:
125
+
126
+ - the `TMDataGrid.Menu` dropdown - `page.getByRole("menu")`, holding
127
+ `columns-toggle`, `columns-toggle-all`, `columns-reset`, `menu-export`,
128
+ `menu-export-selected`
129
+ - a column's menu, opened by `header-menu` (hover the header first) or a right
130
+ click on the header - `page.getByRole("menu")`; its items (sort, filter,
131
+ group, pin, hide) carry no part, so reach one by role and label,
132
+ `menu.getByRole("menuitem", { name: "Group by Location" })` - a translated
133
+ label
134
+ - the `header-filter-operator` menu - `page.getByRole("menu")`
135
+ - the export column picker - `page.getByRole("dialog")`, holding the
136
+ `export-*` parts
137
+ - the listbox of every `Select` or `MultiSelect` the grid renders -
138
+ `page-size`, `filter-column`, `filter-operator`, and the `filter-value` of a
139
+ boolean or select-type filter - the element named by the input's
140
+ `aria-controls`; each option carries its value in `value`
141
+
142
+ One menu or dialog is open at a time, so the page-level locator is unambiguous.
143
+ `filter-popup` and `filter-sidebar` render inside the root.
144
+
89
145
  ## A page object
90
146
 
147
+ The full class is on the Testing docs page and in the repository at
148
+ `playwright/support/DataGrid.ts`; it is the one the grid's own suite runs
149
+ against the docs demos. The shape:
150
+
91
151
  ```ts
92
152
  import { type Locator, type Page, expect } from "@playwright/test";
93
153
 
94
154
  type PartKey = { rowId?: string; columnId?: string };
95
155
 
96
156
  export class DataGrid {
157
+ readonly page: Page;
97
158
  readonly root: Locator;
98
159
  readonly grid: Locator;
99
160
 
100
- constructor(page: Page, testId: string) {
101
- this.root = page.getByTestId(testId);
102
- this.grid = this.root.getByRole("table");
161
+ /** `root` is the element carrying `data-dg-root`. */
162
+ constructor(root: Locator) {
163
+ this.page = root.page();
164
+ this.root = root;
165
+ this.grid = root.getByRole("table").or(root.getByRole("grid"));
166
+ }
167
+
168
+ static byTestId(page: Page, testId: string): DataGrid {
169
+ return new DataGrid(page.getByTestId(testId));
103
170
  }
104
171
 
105
172
  part(name: string, key: PartKey = {}): Locator {
106
- return this.root.locator(
107
- `[data-dg-part="${name}"]` +
108
- (key.rowId === undefined ? "" : `[data-row-id="${key.rowId}"]`) +
109
- (key.columnId === undefined ? "" : `[data-column-id="${key.columnId}"]`),
110
- );
173
+ return this.root.locator(partSelector(name, key));
174
+ }
175
+
176
+ /** A part inside the open menu dropdown, which renders in a portal. */
177
+ menuPart(name: string, key: PartKey = {}): Locator {
178
+ return this.page.getByRole("menu").locator(partSelector(name, key));
111
179
  }
112
180
 
113
181
  cell({ rowId, columnId }: { rowId: string; columnId: string }): Locator {
114
182
  return this.root.locator(
115
- `[data-row-id="${rowId}"][data-column-id="${columnId}"]`,
183
+ `[data-row-id="${rowId}"][data-column-id="${columnId}"]:not([data-dg-part])`,
116
184
  );
117
185
  }
118
186
 
187
+ async sortBy(columnId: string): Promise<void> {
188
+ await this.part("header", { columnId }).click();
189
+ }
190
+
119
191
  async expectRowCount(count: number): Promise<void> {
120
192
  await expect(this.grid).toHaveAttribute("data-dg-row-count", String(count));
121
193
  }
@@ -126,6 +198,31 @@ export class DataGrid {
126
198
  }
127
199
  ```
128
200
 
201
+ The full class adds `search`, `filterBy` (adds a filter row for the column
202
+ when the panel has none; fills the built-in text and number inputs, so a
203
+ select-type filter takes `chooseOption` on its `filter-value` instead),
204
+ `toggleColumn`, `openColumnMenu` (hovers the header, clicks `header-menu`,
205
+ returns the menu), `chooseOption` (an option of a portaled `Select`, by value),
206
+ and the editing methods of the `testing-editing` skill.
207
+
208
+ ## Recipes
209
+
210
+ `grid` is a `DataGrid`; the parts are the steps, the attributes are the proof.
211
+
212
+ | Interaction | Steps | Assertion |
213
+ | --- | --- | --- |
214
+ | Quick search | `grid.search("Cecilia")`; `search-clear` | `grid.expectRowCount(10)`, then the full count |
215
+ | Sort | `grid.sortBy("lastName")` once, then again | `aria-sort` on `header`: `ascending`, then `descending` |
216
+ | Filter | `grid.filterBy({ columnId, value })`; `filter-clear-all` | `grid.expectRowCount(n)`, then the full count |
217
+ | Remove a filter | `filter-remove` in its `filter-row`, or `filter-pill-remove` in its `filter-pill` | the `filter-pill` has count 0; the row count |
218
+ | Hide a column | `grid.toggleColumn("location")`; `grid.menuPart("columns-reset")` | the `header` has count 0, then is visible |
219
+ | Page | `page-next`; `grid.chooseOption({ select: grid.part("page-size"), value: "50" })` | `page-range` text changes, first `row` has a new `data-row-id`; `grid.expectRowCount(50)` |
220
+ | Select rows | `select-row` of a row; `select-all` | `data-selected="true"` on the `row`; `[data-dg-part="row"]:not([data-selected])` has count 0 |
221
+ | Group | `grid.openColumnMenu("location")`, the "Group by" item; `group-toggle` of a group row | rows carry `data-grouped`; `data-dg-row-count` grows on expand, shrinks on collapse |
222
+ | Load more | scroll `data-dg-scroll-container` to `scrollHeight` | `data-dg-row-count` grows; `grid.expectSettled()` |
223
+ | Export | `menu-button`, then `menu-export` in the menu | `page.waitForEvent("download")`, `download.suggestedFilename()` |
224
+ | Edit a cell | `grid.cell(...).dblclick()`, `grid.fillRow(rowId, { columnId: value })`, Enter | the cell's text; Escape instead of Enter leaves it unchanged |
225
+
129
226
  ## Virtualization
130
227
 
131
228
  Only the rows in the viewport plus overscan are in the DOM. A row at index 500
@@ -138,7 +235,10 @@ otherwise. (`aria-rowcount` also counts the header and summary rows.)
138
235
  **Reach a row by narrowing to it** - filter or search. Faster, more stable, and
139
236
  what a user would do. Where the row must be reached in place,
140
237
  `grid.scrollToRow({ rowId, align })` moves the virtualizer and answers whether
141
- the row was reachable; from Playwright that needs the app to expose the api.
238
+ the row was reachable; from Playwright that needs the app to expose the api on
239
+ `window`. Scrolling the element carrying `data-dg-scroll-container` moves the
240
+ virtualizer without it, and is also how an infinite-scroll grid is made to
241
+ load its next page.
142
242
 
143
243
  ## Waiting
144
244
 
@@ -165,14 +265,32 @@ layout, so the count depends on the stubbed element size. Assert
165
265
  ### getByRole("cell") on a grid with cell selection
166
266
 
167
267
  `cellSelection` turns every `cell` into a `gridcell`, and the grid's `table`
168
- into a `grid`, because a widget with a keyboard cursor is not a static table.
169
- Tests written on the role break when the feature is switched on. Query cells by
268
+ into a `grid`. Tests written on the role break when the feature is switched on. Query cells by
170
269
  `[data-row-id][data-column-id]` instead.
171
270
 
271
+ ### Matching a cell while it is edited
272
+
273
+ `[data-row-id="42"][data-column-id="total"]` matches the cell and the `editor`
274
+ inside it once the cell is open, and strict mode fails on the pair. Add
275
+ `:not([data-dg-part])`.
276
+
277
+ ### Clicking header-sort to sort
278
+
279
+ The arrow is `display: none` until the header is hovered, so the click waits
280
+ for visibility and times out. Click `header`; `aria-sort` on it is the result.
281
+
282
+ ### Reaching the menu through the root
283
+
284
+ `orders.locator('[data-dg-part="columns-toggle"]')` never resolves: the
285
+ dropdown renders in a portal at the end of `<body>`. Use
286
+ `page.getByRole("menu")` after `menu-button` is clicked. The same goes for the
287
+ export picker (`dialog`) and every `Select` listbox (`aria-controls`).
288
+
172
289
  ### Expecting a row far down the list to exist
173
290
 
174
- `dg-row-450` has no element until it is scrolled to, so the locator times out
175
- with no useful message. Filter or search down to it first.
291
+ `[data-row-id="450"]` has no element until it is scrolled to, so the locator times out
292
+ with no useful message. Filter or search down to it first, or scroll
293
+ `data-dg-scroll-container`.
176
294
 
177
295
  ### Unscoped parts with two grids on a page
178
296
 
@@ -196,4 +314,4 @@ different column than it did. Use `[data-column-id]`.
196
314
 
197
315
  `data-dg-part="loading"` only renders where `TMDataGrid.LoadingIndicator` was
198
316
  placed, and only while `meta.loading` is true. `aria-busy` on the grid is set
199
- regardless of whether that component is rendered.
317
+ regardless of whether that component is rendered.