@anchrd/intel-ui 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,49 +1,227 @@
1
1
  import type { BoardColumn, BoardTaskFilter } from "@anchrd/intel-contract/board";
2
+ import {
3
+ type ColumnVisibilityState,
4
+ columnGroupingFeature,
5
+ columnVisibilityFeature,
6
+ createColumnHelper,
7
+ createExpandedRowModel,
8
+ createGroupedRowModel,
9
+ createSortedRowModel,
10
+ type ExpandedState,
11
+ flexRender,
12
+ type GroupingState,
13
+ type Row as LibRow,
14
+ rowExpandingFeature,
15
+ rowSortingFeature,
16
+ type SortingState,
17
+ sortFn_alphanumeric,
18
+ sortFn_basic,
19
+ tableFeatures,
20
+ useTable,
21
+ } from "@tanstack/react-table";
22
+ import { ChevronDown, ChevronRight, Columns3, Filter, LayoutList } from "lucide-react";
23
+ import { useMemo, useState } from "react";
24
+ import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
2
25
  import { isFiltering } from "@/board/board-data/board-data.ts";
3
26
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
4
- import { useI18n } from "@/i18n/i18n-context.tsx";
5
- import { useUserName } from "@/user-name/user-name.ts";
6
27
  import {
7
- ariaSortOf,
8
- type GroupKey,
9
- groupRows,
10
- nextSort,
11
- type Row,
12
- rowsOf,
13
- type Sort,
14
- type SortKey,
15
- sortRows,
16
- } from "./board-table.ts";
17
-
18
- const headers: { key: SortKey; labelKey: string }[] = [
19
- { key: "title", labelKey: "board.column.title" },
20
- { key: "status", labelKey: "board.column.status" },
21
- { key: "assignee", labelKey: "board.column.assignee" },
22
- { key: "due", labelKey: "board.column.due" },
23
- // ⚠️ `dependsOn` is a column of its own, and that is the condition under which the board graph
24
- // stays parked (#651, #653). Stored but undrawn, a dependency is a card that waits for a reason
25
- // nobody can see.
26
- { key: "dependsOn", labelKey: "board.column.dependsOn" },
27
- ];
28
+ DropdownMenu,
29
+ DropdownMenuCheckboxItem,
30
+ DropdownMenuContent,
31
+ DropdownMenuRadioGroup,
32
+ DropdownMenuRadioItem,
33
+ DropdownMenuTrigger,
34
+ } from "@/components/ui/dropdown-menu";
35
+ import { useI18n } from "@/i18n/i18n-context.tsx";
36
+ import { type Row, rowsOf } from "./board-table.ts";
37
+
38
+ /**
39
+ * ⚠️ Registered one by one rather than through `stockFeatures`. Every feature this table does not
40
+ * use — pagination, selection, pinning, resizing, faceting — would otherwise sit in the bundle of a
41
+ * screen that never calls it, and the list below is also the honest answer to "what can this table
42
+ * do".
43
+ */
44
+ const features = tableFeatures({
45
+ columnGroupingFeature,
46
+ columnVisibilityFeature,
47
+ rowExpandingFeature,
48
+ rowSortingFeature,
49
+ // ⚠️ A row-model slot has to stand AFTER the feature it needs: sorting, then grouping, then
50
+ // expanding — the order the rows actually pass through.
51
+ sortedRowModel: createSortedRowModel(),
52
+ groupedRowModel: createGroupedRowModel(),
53
+ expandedRowModel: createExpandedRowModel(),
54
+ sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
55
+ });
56
+
57
+ /** One row of THIS table — the library's row type, bound to these features and this data. */
58
+ type TableRow = LibRow<typeof features, Row>;
59
+
60
+ const helper = createColumnHelper<typeof features, Row>();
61
+
62
+ // The one place a grouping is offered. Not free-form: a board has fixed fields, and grouping by
63
+ // anything else would be grouping by something nobody can create.
64
+ /**
65
+ * ⚠️ **Empty becomes `undefined`, never `null`.** `sortUndefined: "last"` fires on `undefined`
66
+ * only; with `null` the row falls through to the comparator, where `null < "2026-08-19"` and
67
+ * `null > "2026-08-19"` are BOTH false. The comparator then claims an equality that is not there,
68
+ * and the order depends on where the empty rows happened to sit. A card without a due date is not
69
+ * the earliest one — it is one whose date nobody knows.
70
+ */
71
+ const orNothing = (value: string | null): string | undefined => value ?? undefined;
28
72
 
29
73
  export function BoardTable({
30
74
  board,
31
75
  filter,
32
76
  onFilter,
33
- sort,
34
- onSort,
35
- group,
36
- onGroup,
77
+ hidden,
78
+ onHidden,
79
+ onOpen,
37
80
  }: {
38
81
  board: BoardHandle;
39
82
  filter: Partial<BoardTaskFilter>;
40
83
  onFilter(next: Partial<BoardTaskFilter>): void;
41
- sort: Sort | null;
42
- onSort(next: Sort | null): void;
43
- group: GroupKey;
44
- onGroup(next: GroupKey): void;
84
+ /**
85
+ * ⚠️ Held ABOVE the view switch, like the filter since #671. This component is unmounted the
86
+ * moment the kanban is shown OR a card is opened, so state kept here would be gone on the way
87
+ * back, and the choice would silently undo itself. Sorting and grouping do stay here: they are
88
+ * about the rows on screen and have no meaning once the rows are gone.
89
+ */
90
+ hidden: ColumnVisibilityState;
91
+ onHidden(next: ColumnVisibilityState): void;
92
+ /** ⚠️ The SAME surface a kanban card opens — one screen for a task, not two that agree today. */
93
+ onOpen(taskId: string): void;
45
94
  }) {
46
95
  const i18n = useI18n();
96
+ const [sorting, setSorting] = useState<SortingState>([]);
97
+ const [grouping, setGrouping] = useState<GroupingState>([]);
98
+ const [expanded, setExpanded] = useState<ExpandedState>(true);
99
+
100
+ const data = useMemo(() => rowsOf(board.columns), [board.columns]);
101
+
102
+ /**
103
+ * ⚠️ `helper.columns([...])` rather than a bare array. The accessors carry different value types
104
+ * (`string`, `string | undefined`), and a plain array makes TypeScript compare their column
105
+ * definitions structurally — where the `header` and `footer` templates are contravariant and the
106
+ * error reads "two different types with this name exist". The helper is the API's own answer to
107
+ * that; it is not a cast, and it keeps every accessor's own type.
108
+ */
109
+ const columns = useMemo(
110
+ () =>
111
+ helper.columns([
112
+ helper.accessor((row) => row.task.title, {
113
+ id: "title",
114
+ header: i18n.t("board.column.title"),
115
+ sortFn: "alphanumeric",
116
+ // Grouping by the title would make one group per row, which is the table again with an
117
+ // extra line above every entry.
118
+ enableGrouping: false,
119
+ cell: ({ row, getValue }) => (
120
+ <span
121
+ className="flex items-center gap-1"
122
+ // The depth is the indent, and it is the only thing that says what hangs under what.
123
+ style={{ paddingLeft: `${row.depth * 20}px` }}
124
+ >
125
+ {row.getCanExpand() ? (
126
+ <button
127
+ type="button"
128
+ aria-label={
129
+ row.getIsExpanded()
130
+ ? i18n.t("board.table.collapse", { row: String(getValue()) })
131
+ : i18n.t("board.table.expand", { row: String(getValue()) })
132
+ }
133
+ onClick={row.getToggleExpandedHandler()}
134
+ className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
135
+ >
136
+ {row.getIsExpanded() ? (
137
+ <ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
138
+ ) : (
139
+ <ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
140
+ )}
141
+ </button>
142
+ ) : (
143
+ <span className="size-4" />
144
+ )}
145
+ <button
146
+ type="button"
147
+ onClick={() => onOpen(row.original.task.id)}
148
+ className="truncate rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
149
+ >
150
+ {String(getValue())}
151
+ </button>
152
+ </span>
153
+ ),
154
+ }),
155
+ helper.accessor((row) => row.columnTitle, {
156
+ id: "status",
157
+ header: i18n.t("board.column.status"),
158
+ sortFn: "alphanumeric",
159
+ }),
160
+ helper.accessor((row) => orNothing(row.task.assigneeId), {
161
+ id: "assignee",
162
+ header: i18n.t("board.column.assignee"),
163
+ sortFn: "alphanumeric",
164
+ sortUndefined: "last",
165
+ // ⚠️ Grouped by the RAW id, drawn as a name. Grouping by what is drawn would put everybody
166
+ // this browser cannot name into one group called "a user account".
167
+ cell: ({ getValue }) => <Assignee id={getValue() ?? null} />,
168
+ }),
169
+ helper.accessor((row) => orNothing(row.task.dueDate), {
170
+ id: "due",
171
+ header: i18n.t("board.column.due"),
172
+ // An ISO date sorts correctly as a string, so `basic` is the whole of the comparison.
173
+ sortFn: "basic",
174
+ sortUndefined: "last",
175
+ enableGrouping: false,
176
+ cell: ({ getValue }) => (
177
+ <span className="tabular-nums">{getValue()?.slice(0, 10) ?? ""}</span>
178
+ ),
179
+ }),
180
+ helper.accessor((row) => orNothing(row.blocking), {
181
+ id: "dependsOn",
182
+ header: i18n.t("board.column.dependsOn"),
183
+ sortFn: "alphanumeric",
184
+ sortUndefined: "last",
185
+ // Grouping by what a card waits for is a real question: everything blocked by the same
186
+ // card, in one place.
187
+ cell: ({ getValue }) => getValue() ?? "",
188
+ }),
189
+ helper.accessor((row) => row.task.labels.join(", "), {
190
+ id: "labels",
191
+ header: i18n.t("board.column.labels"),
192
+ enableSorting: false,
193
+ // ⚠️ NOT groupable. A card carries several labels, and grouping a joined string groups by
194
+ // the accident of their order: `a, b` becomes a group of its own rather than appearing
195
+ // under `a` and under `b`. That is not "grouped by label", and it looks close enough to
196
+ // it that nobody checks.
197
+ enableGrouping: false,
198
+ }),
199
+ ]),
200
+ [i18n, onOpen],
201
+ );
202
+
203
+ const table = useTable({
204
+ features,
205
+ columns,
206
+ data,
207
+ // The hierarchy the ticket asks for, in one option. Everything else about depth — the indent,
208
+ // the chevron, which rows exist at all — follows from it.
209
+ getSubRows: (row: Row) => row.subRows,
210
+ getRowId: (row: Row) => row.task.id,
211
+ state: { sorting, columnVisibility: hidden, grouping, expanded },
212
+ onSortingChange: setSorting,
213
+ onColumnVisibilityChange: (next) => onHidden(typeof next === "function" ? next(hidden) : next),
214
+ onGroupingChange: setGrouping,
215
+ onExpandedChange: setExpanded,
216
+ /**
217
+ * ⚠️ **Off, and it has to be off.** TanStack resets the expanded state whenever the row
218
+ * structure changes, and on a board that happens constantly: every card edit, every drag and
219
+ * every archive flip hands `data` a new array. With the reset on, opening an epic and then
220
+ * renaming anything on the board closes it again — and switching grouping on collapsed every
221
+ * group the moment it appeared.
222
+ */
223
+ autoResetExpanded: false,
224
+ });
47
225
 
48
226
  if (board.isPending) {
49
227
  return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
@@ -56,144 +234,267 @@ export function BoardTable({
56
234
  );
57
235
  }
58
236
 
59
- const groups = groupRows(sortRows(rowsOf(board.columns), sort), group);
237
+ const grouped = grouping[0] ?? "none";
238
+ const groupable = table.getAllLeafColumns().filter((column) => column.getCanGroup());
60
239
  const configured: BoardColumn[] = board.columns
61
240
  .filter((entry) => entry.unknown !== true)
62
241
  .map((entry) => entry.column);
63
- // ⚠️ The answer this is measured against is the FILTERED one. A board full of cards, narrowed to
64
- // a column that happens to be empty, would otherwise be told it has no cards at all — and the
65
- // reader would go looking for the cards they just watched disappear.
66
- const empty = board.columns.every((entry) => entry.cards.length === 0);
67
- const emptyMessage = isFiltering(filter) ? "board.filterEmpty" : "board.tableEmpty";
242
+ const rows = table.getRowModel().rows;
243
+ const span = table.getVisibleLeafColumns().length;
244
+ // ⚠️ Measured on the ANSWER, not on the rows on screen. The filter travels to the server, so an
245
+ // empty answer under a filter means "nothing matches" while an empty answer without one means
246
+ // "this board has no cards" two different sentences for the same zero.
247
+ const empty = data.length === 0;
68
248
 
69
249
  return (
70
250
  <div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-busy={board.isWriting}>
71
- <div className="flex flex-wrap items-center gap-4 text-sm">
72
- <label className="flex items-center gap-2">
73
- {i18n.t("board.filter.status")}
74
- {/* ⚠️ This one TRAVELS. `useBoard` carries the filter in the query key and sends it, so
75
- the server answers with the cards that match. A view that fetched everything and hid
76
- rows in the browser would look identical on screen and be a different thing over the
77
- wire — which is why the test asserts the argument of the second request. */}
78
- <select
79
- className="rounded-md border bg-background px-2 py-1"
80
- value={filter.status ?? ""}
81
- onChange={(event) =>
82
- onFilter(
83
- event.target.value === ""
84
- ? { ...filter, status: undefined }
85
- : { ...filter, status: event.target.value },
86
- )
87
- }
251
+ {/* ⚠️ Icons only what each one offers is in its popup. But the ACTIVE state has to stay
252
+ readable without opening anything: the icon fills, and the quiet line beside it names what
253
+ is set. An icon trio that says nothing about its own state forces a click to find out. */}
254
+ <div className="flex flex-wrap items-center gap-1 text-sm">
255
+ <DropdownMenu>
256
+ <DropdownMenuTrigger
257
+ aria-label={i18n.t("board.table.columns")}
258
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted"
88
259
  >
89
- <option value="">{i18n.t("board.filter.all")}</option>
90
- {configured.map((column) => (
91
- <option key={column.id} value={column.id}>
92
- {column.title}
93
- </option>
260
+ <Columns3 aria-hidden="true" className="size-4" />
261
+ </DropdownMenuTrigger>
262
+ <DropdownMenuContent align="start">
263
+ {table.getAllLeafColumns().map((column) => (
264
+ <DropdownMenuCheckboxItem
265
+ key={column.id}
266
+ checked={column.getIsVisible()}
267
+ onCheckedChange={(next) => column.toggleVisibility(next)}
268
+ >
269
+ {String(column.columnDef.header)}
270
+ </DropdownMenuCheckboxItem>
94
271
  ))}
95
- </select>
96
- </label>
97
- <label className="flex items-center gap-2">
98
- {i18n.t("board.groupBy")}
99
- <select
100
- className="rounded-md border bg-background px-2 py-1"
101
- value={group}
102
- onChange={(event) => onGroup(event.target.value as GroupKey)}
272
+ </DropdownMenuContent>
273
+ </DropdownMenu>
274
+
275
+ <DropdownMenu>
276
+ <DropdownMenuTrigger
277
+ aria-label={i18n.t("board.groupBy")}
278
+ className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted ${grouped === "none" ? "" : "bg-accent text-accent-foreground"}`}
103
279
  >
104
- <option value="none">{i18n.t("board.group.none")}</option>
105
- <option value="status">{i18n.t("board.group.status")}</option>
106
- <option value="assignee">{i18n.t("board.group.assignee")}</option>
107
- </select>
108
- </label>
280
+ <LayoutList aria-hidden="true" className="size-4" />
281
+ </DropdownMenuTrigger>
282
+ <DropdownMenuContent align="start">
283
+ <DropdownMenuRadioGroup
284
+ value={grouped}
285
+ onValueChange={(next) => setGrouping(next === "none" ? [] : [next])}
286
+ >
287
+ <DropdownMenuRadioItem value="none">
288
+ {i18n.t("board.group.none")}
289
+ </DropdownMenuRadioItem>
290
+ {/* ⚠️ Read off the COLUMNS, not a list written here. A hand-kept list is a second
291
+ truth about which columns can group: add a column and the menu stays silent about
292
+ it, and nothing fails — `enableGrouping` on the column itself is the one answer. */}
293
+ {groupable.map((column) => (
294
+ <DropdownMenuRadioItem key={column.id} value={column.id}>
295
+ {String(column.columnDef.header)}
296
+ </DropdownMenuRadioItem>
297
+ ))}
298
+ </DropdownMenuRadioGroup>
299
+ </DropdownMenuContent>
300
+ </DropdownMenu>
301
+
302
+ <DropdownMenu>
303
+ <DropdownMenuTrigger
304
+ aria-label={i18n.t("board.filter.status")}
305
+ className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted ${filter.status === undefined ? "" : "bg-accent text-accent-foreground"}`}
306
+ >
307
+ <Filter aria-hidden="true" className="size-4" />
308
+ </DropdownMenuTrigger>
309
+ {/* ⚠️ This one TRAVELS. `useBoard` carries it in the query key and sends it, so the server
310
+ answers with the cards that match — a view that fetched everything and hid rows in the
311
+ browser would look identical on screen and be a different thing over the wire. */}
312
+ <DropdownMenuContent align="start">
313
+ <DropdownMenuRadioGroup
314
+ value={filter.status ?? ""}
315
+ onValueChange={(next) =>
316
+ onFilter(
317
+ next === "" ? { ...filter, status: undefined } : { ...filter, status: next },
318
+ )
319
+ }
320
+ >
321
+ <DropdownMenuRadioItem value="">{i18n.t("board.filter.all")}</DropdownMenuRadioItem>
322
+ {configured.map((column) => (
323
+ <DropdownMenuRadioItem key={column.id} value={column.id}>
324
+ {column.title}
325
+ </DropdownMenuRadioItem>
326
+ ))}
327
+ </DropdownMenuRadioGroup>
328
+ </DropdownMenuContent>
329
+ </DropdownMenu>
330
+
331
+ <span className="ml-2 text-xs text-muted-foreground">
332
+ {[
333
+ grouped === "none"
334
+ ? null
335
+ : i18n.t("board.table.groupedBy", {
336
+ column: String(
337
+ groupable.find((column) => column.id === grouped)?.columnDef.header ?? grouped,
338
+ ),
339
+ }),
340
+ filter.status === undefined
341
+ ? null
342
+ : i18n.t("board.table.filteredBy", {
343
+ column: configured.find((c) => c.id === filter.status)?.title ?? filter.status,
344
+ }),
345
+ ]
346
+ .filter(Boolean)
347
+ .join(" · ")}
348
+ </span>
109
349
  </div>
110
350
 
111
351
  <div className="min-h-0 flex-1 overflow-auto">
112
352
  <table className="w-full border-collapse text-left text-sm">
113
353
  <thead>
114
- <tr className="border-b">
115
- {headers.map((header) => (
116
- <th
117
- key={header.key}
118
- scope="col"
119
- aria-sort={ariaSortOf(sort, header.key)}
120
- className="p-2 font-medium"
121
- >
122
- <button
123
- type="button"
124
- onClick={() => onSort(nextSort(sort, header.key))}
125
- className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
354
+ {table.getHeaderGroups().map((group) => (
355
+ <tr key={group.id} className="border-b">
356
+ {group.headers.map((header) => (
357
+ <th
358
+ key={header.id}
359
+ scope="col"
360
+ aria-sort={
361
+ header.column.getIsSorted() === "asc"
362
+ ? "ascending"
363
+ : header.column.getIsSorted() === "desc"
364
+ ? "descending"
365
+ : "none"
366
+ }
367
+ className="px-2 py-3 font-medium"
126
368
  >
127
- {i18n.t(header.labelKey)}
128
- <span aria-hidden="true" className="text-xs text-muted-foreground">
129
- {sort?.key === header.key ? (sort.direction === "asc" ? "▲" : "▼") : ""}
130
- </span>
131
- </button>
132
- </th>
133
- ))}
134
- <th scope="col" className="p-2 font-medium">
135
- {i18n.t("board.column.labels")}
136
- </th>
137
- </tr>
138
- </thead>
139
- {groups.map((entry) => (
140
- <tbody key={entry.key ?? "-none-"}>
141
- {group === "none" ? null : (
142
- <tr className="border-b bg-muted/40">
143
- <th scope="rowgroup" colSpan={headers.length + 1} className="p-2 text-left">
144
- {group === "assignee" ? (
145
- <Assignee id={entry.key} />
369
+ {header.column.getCanSort() ? (
370
+ <button
371
+ type="button"
372
+ onClick={header.column.getToggleSortingHandler()}
373
+ className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
374
+ >
375
+ {String(header.column.columnDef.header)}
376
+ <span aria-hidden="true" className="text-xs text-muted-foreground">
377
+ {header.column.getIsSorted() === "asc"
378
+ ? "\u25b2"
379
+ : header.column.getIsSorted() === "desc"
380
+ ? "\u25bc"
381
+ : ""}
382
+ </span>
383
+ </button>
146
384
  ) : (
147
- (entry.key ?? i18n.t("board.unassigned"))
385
+ String(header.column.columnDef.header)
148
386
  )}
149
- <span className="ml-2 text-xs font-normal text-muted-foreground tabular-nums">
150
- {entry.rows.length}
151
- </span>
152
387
  </th>
388
+ ))}
389
+ </tr>
390
+ ))}
391
+ </thead>
392
+ {/* ⚠️ One `tbody` PER GROUP, and the heading scopes to it. That is what actually
393
+ associates a heading with the rows under it: `scope="row"` binds to the heading's own
394
+ row and says nothing about the ones below, so a single `tbody` with headings sprinkled
395
+ through it reads to a screen reader as one long list with odd rows in it. */}
396
+ {groupsOf(rows).map((group) => (
397
+ <tbody key={group.key}>
398
+ {group.heading === undefined ? null : <GroupRow row={group.heading} span={span} />}
399
+ {group.rows.map((row) => (
400
+ <tr key={row.id} className="border-b last:border-0">
401
+ {row.getVisibleCells().map((cell) => (
402
+ // ⚠️ Room above and below. A table one has to decipher rather than read is a
403
+ // table people stop reading.
404
+ <td key={cell.id} className="px-2 py-3">
405
+ {/* ⚠️ `flexRender` with the cell's OWN context, not the cell. A hand-rolled
406
+ call that passes the cell itself hands the renderer an object without the
407
+ context's fields, and the first `{ row }` destructuring throws — the whole
408
+ table then vanishes behind the router's error boundary, which reads as
409
+ "the table does not render" rather than as a wrong argument. */}
410
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
411
+ </td>
412
+ ))}
153
413
  </tr>
154
- )}
155
- {entry.rows.map((row) => (
156
- <Line key={row.task.id} row={row} />
157
414
  ))}
158
415
  </tbody>
159
416
  ))}
160
417
  </table>
161
- {empty ? <p className="p-6 text-sm text-muted-foreground">{i18n.t(emptyMessage)}</p> : null}
418
+ {empty ? (
419
+ <p className="p-6 text-sm text-muted-foreground">
420
+ {i18n.t(isFiltering(filter) ? "board.filterEmpty" : "board.tableEmpty")}
421
+ </p>
422
+ ) : null}
162
423
  </div>
163
424
  </div>
164
425
  );
165
426
  }
166
427
 
167
428
  /**
168
- * Who a card belongs to, by the one rule this application has for saying so.
429
+ * The rows split into groups, each with the heading that belongs to it.
169
430
  *
170
- * ⚠️ **Never the raw id.** Intel has no user directory `useUserName` can resolve exactly one id,
171
- * the signed-in person's and an id under the heading "assigned to" is not an answer to "who", it
172
- * is the question again in smaller type. It would also put a Gate principal identifier on a screen
173
- * that has no reason to carry one (`user-name.ts`, #258). Somebody this browser cannot name is
174
- * drawn as somebody, which is true, and the word changes in exactly one place when Intel gains a
175
- * lookup (#261).
431
+ * With no grouping there is exactly one group and no heading, so the view keeps ONE code path
432
+ * rather than a branch that only the grouped case exercises.
176
433
  */
177
- function Assignee({ id }: { id: string | null }) {
178
- const i18n = useI18n();
179
- const name = useUserName(id);
180
- if (id === null) return <>{i18n.t("board.unassigned")}</>;
181
- return <>{name ?? i18n.t("node.someUser")}</>;
434
+ function groupsOf(rows: TableRow[]): { key: string; heading?: TableRow; rows: TableRow[] }[] {
435
+ const groups: { key: string; heading?: TableRow; rows: TableRow[] }[] = [];
436
+ for (const row of rows) {
437
+ if (row.getIsGrouped()) groups.push({ key: row.id, heading: row, rows: [] });
438
+ else if (groups.length === 0) groups.push({ key: "all", rows: [row] });
439
+ else groups[groups.length - 1]?.rows.push(row);
440
+ }
441
+ return groups;
182
442
  }
183
443
 
184
- function Line({ row }: { row: Row }) {
444
+ /**
445
+ * The heading of one group.
446
+ *
447
+ * ⚠️ A component rather than a branch in the loop, because the label needs a hook: grouping by who
448
+ * a card belongs to groups by the raw Gate id, and that id must not reach the screen — nor an
449
+ * `aria-label`, which is screen for everybody using one (`user-name.ts`, #258).
450
+ */
451
+ function GroupRow({ row, span }: { row: TableRow; span: number }) {
452
+ const i18n = useI18n();
453
+ const column = row.groupingColumnId;
454
+ const value = column === undefined ? undefined : row.getGroupingValue(column);
455
+ const assignee = column === "assignee";
456
+ const resolved = useAssigneeLabel(assignee ? ((value as string | null) ?? null) : null);
457
+ const label = assignee
458
+ ? resolved
459
+ : value === undefined || value === null || value === ""
460
+ ? i18n.t("board.group.empty")
461
+ : String(value);
462
+ // ⚠️ Every row of the group, not `row.subRows`. A grouped row's `subRows` holds the TOP level
463
+ // only, so an epic with three subtasks under it counts as one — a heading that says `2` above
464
+ // four visible rows.
465
+ const count = row.getLeafRows().length;
466
+
185
467
  return (
186
- <tr className="border-b last:border-0">
187
- <td className="p-2">{row.task.title}</td>
188
- <td className="p-2">{row.columnTitle}</td>
189
- <td className="p-2 text-muted-foreground">
190
- <Assignee id={row.task.assigneeId} />
191
- </td>
192
- {/* The date, not the time: a board deals in days, and a timestamp in a cell is noise that
193
- pushes the columns beside it off the screen. */}
194
- <td className="p-2 tabular-nums">{row.task.dueDate?.slice(0, 10) ?? ""}</td>
195
- <td className="p-2">{row.blocking ?? ""}</td>
196
- <td className="p-2 text-muted-foreground">{row.task.labels.join(", ")}</td>
468
+ <tr className="border-b bg-muted/40">
469
+ <th scope="rowgroup" colSpan={span} className="px-2 py-2 text-left font-medium">
470
+ <button
471
+ type="button"
472
+ onClick={row.getToggleExpandedHandler()}
473
+ aria-label={i18n.t(row.getIsExpanded() ? "board.table.collapse" : "board.table.expand", {
474
+ row: label,
475
+ })}
476
+ className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
477
+ >
478
+ {row.getIsExpanded() ? (
479
+ <ChevronDown aria-hidden="true" className="size-4" />
480
+ ) : (
481
+ <ChevronRight aria-hidden="true" className="size-4" />
482
+ )}
483
+ {label}
484
+ <span className="text-xs text-muted-foreground tabular-nums">{count}</span>
485
+ </button>
486
+ </th>
197
487
  </tr>
198
488
  );
199
489
  }
490
+
491
+ /**
492
+ * Who a card belongs to, by the one rule this application has for saying so.
493
+ *
494
+ * ⚠️ **Never the raw id.** Intel has no user directory — `useUserName` resolves exactly one id, the
495
+ * signed-in person's — and an id under the heading "assigned to" is not an answer to "who", it is
496
+ * the question again in smaller type (#258).
497
+ */
498
+ function Assignee({ id }: { id: string | null }) {
499
+ return <>{useAssigneeLabel(id)}</>;
500
+ }