@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.
- package/package.json +6 -2
- package/src/access-summary/access-summary.tsx +1 -11
- package/src/app/app-tree/app-tree.tsx +37 -0
- package/src/board/board-assignee/board-assignee.ts +18 -0
- package/src/board/board-data/board-data.ts +31 -5
- package/src/board/board-data/board-data.types.ts +10 -1
- package/src/board/board-kanban/board-kanban.ts +63 -9
- package/src/board/board-kanban/board-kanban.tsx +371 -63
- package/src/board/board-panel/board-panel.tsx +222 -31
- package/src/board/board-settings/board-settings.tsx +209 -0
- package/src/board/board-stripes/board-stripes.ts +128 -0
- package/src/board/board-table/board-table.ts +23 -105
- package/src/board/board-table/board-table.tsx +436 -135
- package/src/board/board-task/board-task.ts +105 -0
- package/src/board/board-task/board-task.tsx +364 -0
- package/src/frontmatter/frontmatter.tsx +11 -2
- package/src/i18n/de.json +44 -3
- package/src/i18n/en.json +44 -3
- package/src/i18n/es.json +44 -3
- package/src/router/selection-search.ts +17 -2
- package/src/styles.css +29 -25
- package/src/user-name/user-name.ts +17 -0
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
</
|
|
96
|
-
</
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
-
{
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
(
|
|
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 ?
|
|
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
|
-
*
|
|
429
|
+
* The rows split into groups, each with the heading that belongs to it.
|
|
169
430
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|