@anchrd/intel-ui 0.16.0 → 0.17.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 +10 -2
- package/src/agent/agent-profile/agent-profile.tsx +2 -0
- package/src/board/board-calendar/board-calendar.tsx +89 -0
- package/src/board/board-card/board-card.tsx +106 -0
- package/src/board/board-data/board-data.ts +241 -0
- package/src/board/board-data/board-data.types.ts +63 -0
- package/src/board/board-detail/board-detail.tsx +629 -0
- package/src/board/board-gantt/board-gantt.ts +545 -0
- package/src/board/board-gantt/board-gantt.tsx +286 -0
- package/src/board/board-graph/board-graph.ts +174 -0
- package/src/board/board-graph/board-graph.tsx +168 -0
- package/src/board/board-items/board-items.ts +183 -0
- package/src/board/board-kanban/board-kanban.ts +97 -0
- package/src/board/board-kanban/board-kanban.tsx +211 -0
- package/src/board/board-status/board-status.ts +59 -0
- package/src/board/board-statuses/board-statuses.ts +63 -0
- package/src/board/board-statuses/board-statuses.tsx +228 -0
- package/src/board/board-table/board-table.ts +33 -0
- package/src/board/board-table/board-table.tsx +413 -0
- package/src/board/board-views/board-views.tsx +68 -0
- package/src/board/board-views/board-views.types.ts +29 -0
- package/src/board/board.tsx +251 -0
- package/src/components/ui/dropdown-menu.tsx +25 -0
- package/src/components/ui/item-calendar.tsx +181 -0
- package/src/components/ui/item-gantt.tsx +463 -0
- package/src/components/ui/kanban.tsx +245 -0
- package/src/components/ui/switch.tsx +25 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
- package/src/i18n/de.json +88 -1
- package/src/i18n/en.json +88 -1
- package/src/i18n/es.json +88 -1
- package/src/kind-icon.ts +12 -1
- package/src/nodes/nodes.tsx +16 -0
- package/src/styles.css +33 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { ArchivedBoardStatusId } from "@anchrd/intel-contract";
|
|
2
|
+
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "@/components/ui/dialog";
|
|
12
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
|
+
import { configurableStatuses, statusIdFrom } from "./board-statuses.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The status list, edited (anchrd/intel#286, `board_configure`).
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ The whole list is written at once and never patched — adding, renaming and reordering are one
|
|
19
|
+
* call, because the order IS the list's order (#285). So the dialog holds a draft and sends it on
|
|
20
|
+
* save; a control that wrote per keystroke would write a version per letter.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ `archived` can be renamed and cannot be removed or moved. The contract refuses a list without
|
|
23
|
+
* it, and a shelf that could be dragged into the middle of the working columns would be a shelf
|
|
24
|
+
* pretending to be a stage.
|
|
25
|
+
*/
|
|
26
|
+
export function BoardStatuses({ board, onClose }: { board: BoardHandle; onClose(): void }) {
|
|
27
|
+
const i18n = useI18n();
|
|
28
|
+
// ⚠️ Seeded once per OPENING, which is why the caller mounts this only while it is open
|
|
29
|
+
// (`board.tsx`) instead of handing it an `open` flag. Radix never reports `onOpenChange(true)`
|
|
30
|
+
// for a dialog whose openness is decided outside it, so a re-seed hung off that callback would be
|
|
31
|
+
// dead code — and the draft would then be whatever the list looked like the first time anybody
|
|
32
|
+
// opened this board. Saving it would write that stale list WHOLE and revert a column somebody
|
|
33
|
+
// else had added in between.
|
|
34
|
+
const [draft, setDraft] = useState(() => configurableStatuses(board.statuses));
|
|
35
|
+
const [added, setAdded] = useState("");
|
|
36
|
+
// ⚠️ `board.configure` lives in `useBoard` and outlives this dialog, so its `isError` is still set
|
|
37
|
+
// the next time the dialog is opened — a refusal from ten minutes ago greeting a list nobody has
|
|
38
|
+
// tried to save yet. A local flag rather than `configure.reset()`, because resetting shared state
|
|
39
|
+
// from a mount is a side effect on something this dialog does not own.
|
|
40
|
+
const [attempted, setAttempted] = useState(false);
|
|
41
|
+
|
|
42
|
+
const working = draft.filter((status) => status.id !== ArchivedBoardStatusId);
|
|
43
|
+
const shelf = draft.find((status) => status.id === ArchivedBoardStatusId);
|
|
44
|
+
|
|
45
|
+
const move = (index: number, delta: number) => {
|
|
46
|
+
const target = index + delta;
|
|
47
|
+
if (target < 0 || target >= working.length) return;
|
|
48
|
+
const next = [...working];
|
|
49
|
+
const [item] = next.splice(index, 1);
|
|
50
|
+
if (item) next.splice(target, 0, item);
|
|
51
|
+
setDraft([...next, ...(shelf ? [shelf] : [])]);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
|
56
|
+
<DialogContent>
|
|
57
|
+
<DialogHeader>
|
|
58
|
+
<DialogTitle>{i18n.t("board.statuses")}</DialogTitle>
|
|
59
|
+
</DialogHeader>
|
|
60
|
+
<ul className="flex flex-col gap-2">
|
|
61
|
+
{working.map((status, index) => (
|
|
62
|
+
<li key={status.id} className="flex items-center gap-2">
|
|
63
|
+
<input
|
|
64
|
+
value={status.label}
|
|
65
|
+
aria-label={i18n.t("board.statusLabel")}
|
|
66
|
+
onChange={(event) => {
|
|
67
|
+
const label = event.currentTarget.value;
|
|
68
|
+
setDraft((current) =>
|
|
69
|
+
current.map((entry) => (entry.id === status.id ? { ...entry, label } : entry)),
|
|
70
|
+
);
|
|
71
|
+
}}
|
|
72
|
+
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
73
|
+
/>
|
|
74
|
+
{/* ⚠️ Which columns mean finished is a property of the column, not its position
|
|
75
|
+
(anchrd/intel#311) — so it is set here, per column, and several may carry it. It is
|
|
76
|
+
what decides whether a task waiting on this one is still blocked. */}
|
|
77
|
+
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
78
|
+
<input
|
|
79
|
+
type="checkbox"
|
|
80
|
+
checked={status.terminal}
|
|
81
|
+
onChange={(event) => {
|
|
82
|
+
const terminal = event.currentTarget.checked;
|
|
83
|
+
setDraft((current) =>
|
|
84
|
+
current.map((entry) =>
|
|
85
|
+
entry.id === status.id ? { ...entry, terminal } : entry,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}}
|
|
89
|
+
className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
90
|
+
/>
|
|
91
|
+
{i18n.t("board.statusTerminal")}
|
|
92
|
+
</label>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
aria-label={i18n.t("board.statusUp")}
|
|
96
|
+
disabled={index === 0}
|
|
97
|
+
onClick={() => move(index, -1)}
|
|
98
|
+
className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
99
|
+
>
|
|
100
|
+
<ChevronUp aria-hidden="true" className="size-4" />
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
aria-label={i18n.t("board.statusDown")}
|
|
105
|
+
disabled={index === working.length - 1}
|
|
106
|
+
onClick={() => move(index, 1)}
|
|
107
|
+
className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
108
|
+
>
|
|
109
|
+
<ChevronDown aria-hidden="true" className="size-4" />
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
aria-label={i18n.t("board.statusRemove", { label: status.label })}
|
|
114
|
+
// A column with tasks still in it cannot be taken away: the server refuses a task
|
|
115
|
+
// in a status the board does not have, so removing it would make the board
|
|
116
|
+
// unwritable rather than tidy.
|
|
117
|
+
disabled={board.tasks.some((task) => task.status === status.id)}
|
|
118
|
+
onClick={() =>
|
|
119
|
+
setDraft((current) => current.filter((entry) => entry.id !== status.id))
|
|
120
|
+
}
|
|
121
|
+
className="grid size-8 place-items-center rounded-md border text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
122
|
+
>
|
|
123
|
+
<X aria-hidden="true" className="size-4" />
|
|
124
|
+
</button>
|
|
125
|
+
</li>
|
|
126
|
+
))}
|
|
127
|
+
{shelf ? (
|
|
128
|
+
<li className="flex items-center gap-2">
|
|
129
|
+
<input
|
|
130
|
+
value={shelf.label}
|
|
131
|
+
aria-label={i18n.t("board.statusLabel")}
|
|
132
|
+
onChange={(event) => {
|
|
133
|
+
const label = event.currentTarget.value;
|
|
134
|
+
setDraft((current) =>
|
|
135
|
+
current.map((entry) =>
|
|
136
|
+
entry.id === ArchivedBoardStatusId ? { ...entry, label } : entry,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}}
|
|
140
|
+
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
141
|
+
/>
|
|
142
|
+
<span className="text-xs text-muted-foreground">{i18n.t("board.shelfFixed")}</span>
|
|
143
|
+
{/* The shelf's flag is shown and cannot be turned off: the contract refuses an explicit
|
|
144
|
+
`false` for it, and a shelf that did not mean finished would hold every archived
|
|
145
|
+
task open as a blocker (anchrd/intel#311). */}
|
|
146
|
+
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
147
|
+
<input
|
|
148
|
+
type="checkbox"
|
|
149
|
+
checked
|
|
150
|
+
disabled
|
|
151
|
+
aria-label={i18n.t("board.statusTerminal")}
|
|
152
|
+
className="size-4 rounded border"
|
|
153
|
+
/>
|
|
154
|
+
{i18n.t("board.statusTerminal")}
|
|
155
|
+
</label>
|
|
156
|
+
</li>
|
|
157
|
+
) : null}
|
|
158
|
+
</ul>
|
|
159
|
+
<form
|
|
160
|
+
className="flex gap-2"
|
|
161
|
+
onSubmit={(event) => {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
const label = added.trim();
|
|
164
|
+
if (label === "") return;
|
|
165
|
+
setDraft((current) => {
|
|
166
|
+
const taken = new Set(current.map((entry) => entry.id));
|
|
167
|
+
const shelfEntry = current.find((entry) => entry.id === ArchivedBoardStatusId);
|
|
168
|
+
return [
|
|
169
|
+
...current.filter((entry) => entry.id !== ArchivedBoardStatusId),
|
|
170
|
+
// A new column is work, not the end of it. Meaning "finished" is something somebody
|
|
171
|
+
// says on purpose, with the checkbox beside it.
|
|
172
|
+
{ id: statusIdFrom(label, taken), label, terminal: false },
|
|
173
|
+
...(shelfEntry ? [shelfEntry] : []),
|
|
174
|
+
];
|
|
175
|
+
});
|
|
176
|
+
setAdded("");
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<input
|
|
180
|
+
value={added}
|
|
181
|
+
onChange={(event) => setAdded(event.currentTarget.value)}
|
|
182
|
+
placeholder={i18n.t("board.addStatus")}
|
|
183
|
+
aria-label={i18n.t("board.addStatus")}
|
|
184
|
+
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
185
|
+
/>
|
|
186
|
+
<button
|
|
187
|
+
type="submit"
|
|
188
|
+
className="h-8 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
189
|
+
>
|
|
190
|
+
{i18n.t("common.create")}
|
|
191
|
+
</button>
|
|
192
|
+
</form>
|
|
193
|
+
{attempted && board.configure.isError ? (
|
|
194
|
+
<p role="alert" className="text-sm text-destructive">
|
|
195
|
+
{i18n.t("node.operationFailed")}
|
|
196
|
+
</p>
|
|
197
|
+
) : null}
|
|
198
|
+
<DialogFooter>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={onClose}
|
|
202
|
+
className="h-9 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
203
|
+
>
|
|
204
|
+
{i18n.t("common.close")}
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
disabled={board.configure.isPending || draft.some((entry) => entry.label.trim() === "")}
|
|
209
|
+
onClick={() => {
|
|
210
|
+
setAttempted(true);
|
|
211
|
+
board.configure.mutate(
|
|
212
|
+
draft.map((entry) => ({
|
|
213
|
+
id: entry.id,
|
|
214
|
+
label: entry.label.trim(),
|
|
215
|
+
terminal: entry.terminal,
|
|
216
|
+
})),
|
|
217
|
+
{ onSuccess: onClose },
|
|
218
|
+
);
|
|
219
|
+
}}
|
|
220
|
+
className="h-9 rounded-md bg-primary px-3 text-sm text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
221
|
+
>
|
|
222
|
+
{board.configure.isPending ? i18n.t("common.saving") : i18n.t("common.save")}
|
|
223
|
+
</button>
|
|
224
|
+
</DialogFooter>
|
|
225
|
+
</DialogContent>
|
|
226
|
+
</Dialog>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract";
|
|
2
|
+
import { rootedParents } from "@/board/board-items/board-items.ts";
|
|
3
|
+
|
|
4
|
+
// A task with the tasks under it, which is the shape `getSubRows` reads. `subRows` is absent rather
|
|
5
|
+
// than empty for a leaf: TanStack treats an empty array as "expandable, with nothing in it" and
|
|
6
|
+
// would draw a chevron that opens onto nothing.
|
|
7
|
+
export interface BoardRow extends BoardTask {
|
|
8
|
+
subRows?: BoardRow[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The board as the tree the table draws (anchrd/intel#286).
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Which task a row hangs under is `rootedParents`, shared with Gantt (anchrd/intel#293) rather
|
|
15
|
+
* than kept here. That walk carries two subtleties — an orphan becomes a root instead of vanishing
|
|
16
|
+
* with its hidden parent, and a cycle is broken at the task that closes it — and two copies of those
|
|
17
|
+
* are two copies that drift, which would show as a task that is a root in one view and a child in
|
|
18
|
+
* the other.
|
|
19
|
+
*/
|
|
20
|
+
export function boardRows(tasks: BoardTask[]): BoardRow[] {
|
|
21
|
+
const parents = rootedParents(tasks);
|
|
22
|
+
const rows = new Map<string, BoardRow>(tasks.map((task) => [task.id, { ...task }]));
|
|
23
|
+
const roots: BoardRow[] = [];
|
|
24
|
+
for (const task of tasks) {
|
|
25
|
+
const row = rows.get(task.id);
|
|
26
|
+
if (!row) continue;
|
|
27
|
+
const parent = parents.get(task.id) ?? null;
|
|
28
|
+
const target = parent === null ? undefined : rows.get(parent);
|
|
29
|
+
if (target === undefined) roots.push(row);
|
|
30
|
+
else target.subRows = [...(target.subRows ?? []), row];
|
|
31
|
+
}
|
|
32
|
+
return roots;
|
|
33
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ColumnFiltersState,
|
|
3
|
+
type ColumnVisibilityState,
|
|
4
|
+
columnFilteringFeature,
|
|
5
|
+
columnGroupingFeature,
|
|
6
|
+
columnVisibilityFeature,
|
|
7
|
+
createColumnHelper,
|
|
8
|
+
createExpandedRowModel,
|
|
9
|
+
createFilteredRowModel,
|
|
10
|
+
createGroupedRowModel,
|
|
11
|
+
createSortedRowModel,
|
|
12
|
+
type ExpandedState,
|
|
13
|
+
filterFn_includesString,
|
|
14
|
+
type GroupingState,
|
|
15
|
+
rowExpandingFeature,
|
|
16
|
+
rowSortingFeature,
|
|
17
|
+
type SortingState,
|
|
18
|
+
sortFn_alphanumeric,
|
|
19
|
+
sortFn_basic,
|
|
20
|
+
tableFeatures,
|
|
21
|
+
useTable,
|
|
22
|
+
} from "@tanstack/react-table";
|
|
23
|
+
import { ChevronDown, ChevronRight, Columns3, Search } from "lucide-react";
|
|
24
|
+
import { useMemo, useState } from "react";
|
|
25
|
+
import { BoardAssigneeLabel } from "@/board/board-card/board-card.tsx";
|
|
26
|
+
import { shownTasks } from "@/board/board-data/board-data.ts";
|
|
27
|
+
import { labelOf, toneOf } from "@/board/board-status/board-status.ts";
|
|
28
|
+
import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
|
|
29
|
+
import {
|
|
30
|
+
DropdownMenu,
|
|
31
|
+
DropdownMenuCheckboxItem,
|
|
32
|
+
DropdownMenuContent,
|
|
33
|
+
DropdownMenuTrigger,
|
|
34
|
+
} from "@/components/ui/dropdown-menu";
|
|
35
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
36
|
+
import { type BoardRow, boardRows } from "./board-table.ts";
|
|
37
|
+
|
|
38
|
+
// ⚠️ Registered one by one rather than through `stockFeatures`. Every feature the board's table does
|
|
39
|
+
// not use — pagination, selection, pinning, resizing, faceting — would otherwise be in the bundle
|
|
40
|
+
// of a screen that never calls it, and the list below is also the honest answer to "what can this
|
|
41
|
+
// table do".
|
|
42
|
+
const features = tableFeatures({
|
|
43
|
+
columnFilteringFeature,
|
|
44
|
+
columnGroupingFeature,
|
|
45
|
+
columnVisibilityFeature,
|
|
46
|
+
rowExpandingFeature,
|
|
47
|
+
rowSortingFeature,
|
|
48
|
+
// ⚠️ A row-model slot has to stand AFTER the feature it needs. Filtering before grouping and
|
|
49
|
+
// expanding last is the order the rows actually pass through.
|
|
50
|
+
filteredRowModel: createFilteredRowModel(),
|
|
51
|
+
sortedRowModel: createSortedRowModel(),
|
|
52
|
+
groupedRowModel: createGroupedRowModel(),
|
|
53
|
+
expandedRowModel: createExpandedRowModel(),
|
|
54
|
+
filterFns: { includesString: filterFn_includesString },
|
|
55
|
+
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const helper = createColumnHelper<typeof features, BoardRow>();
|
|
59
|
+
|
|
60
|
+
// The one place a grouping is offered. Not free-form: a board has fixed fields (#285) and there is
|
|
61
|
+
// no UI to add one, so grouping by anything else would be grouping by something nobody can create.
|
|
62
|
+
const groupBy = ["none", "status", "assignee"] as const;
|
|
63
|
+
type GroupBy = (typeof groupBy)[number];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The board as a table (anchrd/intel#286).
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ The hierarchy is `getSubRows` and nothing else. Epic, task and subtask are a DEPTH in this
|
|
69
|
+
* schema and not three types (#285), so the table has one row shape and indents it by
|
|
70
|
+
* `row.depth` — there is no second column saying which of the three a row is, because the schema
|
|
71
|
+
* has no such fact to show.
|
|
72
|
+
*
|
|
73
|
+
* ⚠️ The columns are fixed and there is no UI to add one. A board is not a table node (`kind:
|
|
74
|
+
* "table"`), and a custom-field editor here would be a second, weaker version of the thing Intel
|
|
75
|
+
* already has for exactly that.
|
|
76
|
+
*/
|
|
77
|
+
export function BoardTable({ board, select, showArchived }: BoardViewProps) {
|
|
78
|
+
const i18n = useI18n();
|
|
79
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
80
|
+
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
81
|
+
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({});
|
|
82
|
+
const [expanded, setExpanded] = useState<ExpandedState>(true);
|
|
83
|
+
const [grouping, setGrouping] = useState<GroupingState>([]);
|
|
84
|
+
|
|
85
|
+
const data = useMemo(
|
|
86
|
+
() => boardRows(shownTasks(board.tasks, showArchived)),
|
|
87
|
+
[board.tasks, showArchived],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const columns = useMemo(
|
|
91
|
+
() =>
|
|
92
|
+
helper.columns([
|
|
93
|
+
helper.accessor("title", {
|
|
94
|
+
id: "title",
|
|
95
|
+
header: i18n.t("common.title"),
|
|
96
|
+
filterFn: "includesString",
|
|
97
|
+
sortFn: "alphanumeric",
|
|
98
|
+
// Grouping by the title would make one group per row, which is the table again with an
|
|
99
|
+
// extra click in front of it.
|
|
100
|
+
enableGrouping: false,
|
|
101
|
+
}),
|
|
102
|
+
helper.accessor((task) => labelOf(task.status, board.statuses), {
|
|
103
|
+
id: "status",
|
|
104
|
+
header: i18n.t("board.column.status"),
|
|
105
|
+
sortFn: "alphanumeric",
|
|
106
|
+
}),
|
|
107
|
+
helper.accessor(
|
|
108
|
+
// ⚠️ Grouped by a STABLE key, never by the rendered name. Most person ids cannot be
|
|
109
|
+
// resolved to a name in this browser (`useUserName`), so grouping by what is drawn would
|
|
110
|
+
// put every unnamed person into one group called "assigned".
|
|
111
|
+
(task) =>
|
|
112
|
+
task.assignee === null
|
|
113
|
+
? ""
|
|
114
|
+
: task.assignee.type === "agent"
|
|
115
|
+
? `agent:${task.assignee.nodeId}`
|
|
116
|
+
: `user:${task.assignee.id}`,
|
|
117
|
+
{
|
|
118
|
+
id: "assignee",
|
|
119
|
+
header: i18n.t("board.column.assignee"),
|
|
120
|
+
sortFn: "alphanumeric",
|
|
121
|
+
cell: ({ row }) =>
|
|
122
|
+
row.original.assignee === null ? (
|
|
123
|
+
<span className="text-muted-foreground">{i18n.t("board.unassigned")}</span>
|
|
124
|
+
) : (
|
|
125
|
+
<BoardAssigneeLabel assignee={row.original.assignee} />
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
),
|
|
129
|
+
/**
|
|
130
|
+
* ⚠️ `undefined` for a task with no due date, and never `null`.
|
|
131
|
+
*
|
|
132
|
+
* `sortUndefined` only fires on `undefined` (`createSortedRowModel`), so with `null` the
|
|
133
|
+
* dateless rows fall through to the sort function — where `null < "2026-08-19"` and
|
|
134
|
+
* `null > "2026-08-19"` are both false. That makes the comparator report "equal" for pairs
|
|
135
|
+
* that are not, which is not a comparator at all: the sort then produces an order that
|
|
136
|
+
* depends on where the dateless rows happened to sit. Observed as a "sorted" column reading
|
|
137
|
+
* 8/6, 8/19, —, 8/14.
|
|
138
|
+
*
|
|
139
|
+
* An ISO date sorts correctly as a string, so `basic` is the whole of the comparison.
|
|
140
|
+
*/
|
|
141
|
+
helper.accessor((task) => task.dueDate ?? undefined, {
|
|
142
|
+
id: "dueDate",
|
|
143
|
+
header: i18n.t("board.column.dueDate"),
|
|
144
|
+
sortFn: "basic",
|
|
145
|
+
sortUndefined: "last",
|
|
146
|
+
enableGrouping: false,
|
|
147
|
+
cell: ({ row }) => {
|
|
148
|
+
const value = row.original.dueDate;
|
|
149
|
+
return value === null ? (
|
|
150
|
+
<span className="text-muted-foreground">—</span>
|
|
151
|
+
) : (
|
|
152
|
+
<time dateTime={value} className="tabular-nums">
|
|
153
|
+
{new Date(`${value}T00:00:00`).toLocaleDateString(i18n.locale)}
|
|
154
|
+
</time>
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
helper.accessor((task) => task.labels.join(", "), {
|
|
159
|
+
id: "labels",
|
|
160
|
+
header: i18n.t("board.column.labels"),
|
|
161
|
+
filterFn: "includesString",
|
|
162
|
+
enableGrouping: false,
|
|
163
|
+
enableSorting: false,
|
|
164
|
+
}),
|
|
165
|
+
]),
|
|
166
|
+
[i18n, board.statuses],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const table = useTable({
|
|
170
|
+
features,
|
|
171
|
+
columns,
|
|
172
|
+
data,
|
|
173
|
+
// The hierarchy the DoD asks for, in one option. Everything else about depth — the indent, the
|
|
174
|
+
// chevron, which rows exist at all — follows from it.
|
|
175
|
+
getSubRows: (row: BoardRow) => row.subRows,
|
|
176
|
+
getRowId: (row: BoardRow) => row.id,
|
|
177
|
+
state: { sorting, columnFilters, columnVisibility, expanded, grouping },
|
|
178
|
+
onSortingChange: setSorting,
|
|
179
|
+
onColumnFiltersChange: setColumnFilters,
|
|
180
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
181
|
+
onExpandedChange: setExpanded,
|
|
182
|
+
onGroupingChange: setGrouping,
|
|
183
|
+
/**
|
|
184
|
+
* ⚠️ Off, and it has to be off.
|
|
185
|
+
*
|
|
186
|
+
* TanStack resets the expanded state whenever the row structure changes, and on a board the row
|
|
187
|
+
* structure changes constantly: every card edit, every drag and every archived-switch flip
|
|
188
|
+
* hands `data` a new array. With the reset on, opening an epic and then renaming anything on the
|
|
189
|
+
* board closes it again — and turning grouping on collapsed every group the moment it appeared,
|
|
190
|
+
* which is how this was found (the groups in the first screenshot were shut).
|
|
191
|
+
*/
|
|
192
|
+
autoResetExpanded: false,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const currentGrouping: GroupBy = (grouping[0] as GroupBy) ?? "none";
|
|
196
|
+
const titleFilter = String(table.getColumn("title")?.getFilterValue() ?? "");
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 p-6">
|
|
200
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
201
|
+
<label className="relative">
|
|
202
|
+
<span className="sr-only">{i18n.t("board.filterTitle")}</span>
|
|
203
|
+
<Search
|
|
204
|
+
aria-hidden="true"
|
|
205
|
+
className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
|
|
206
|
+
/>
|
|
207
|
+
<input
|
|
208
|
+
type="search"
|
|
209
|
+
value={titleFilter}
|
|
210
|
+
placeholder={i18n.t("board.filterTitle")}
|
|
211
|
+
onChange={(event) =>
|
|
212
|
+
table.getColumn("title")?.setFilterValue(event.currentTarget.value || undefined)
|
|
213
|
+
}
|
|
214
|
+
className="h-8 w-56 rounded-md border bg-background pr-3 pl-8 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
215
|
+
/>
|
|
216
|
+
</label>
|
|
217
|
+
<label className="flex items-center gap-2 text-sm">
|
|
218
|
+
<span className="text-muted-foreground">{i18n.t("board.groupBy")}</span>
|
|
219
|
+
<select
|
|
220
|
+
value={currentGrouping}
|
|
221
|
+
onChange={(event) => {
|
|
222
|
+
const next = event.currentTarget.value as GroupBy;
|
|
223
|
+
setGrouping(next === "none" ? [] : [next]);
|
|
224
|
+
// A group that opens closed is a screen with nothing on it. Regrouping therefore
|
|
225
|
+
// expands again, which is also the state the table starts in.
|
|
226
|
+
setExpanded(true);
|
|
227
|
+
}}
|
|
228
|
+
className="h-8 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
229
|
+
>
|
|
230
|
+
{groupBy.map((option) => (
|
|
231
|
+
<option key={option} value={option}>
|
|
232
|
+
{i18n.t(`board.groupBy.${option}`)}
|
|
233
|
+
</option>
|
|
234
|
+
))}
|
|
235
|
+
</select>
|
|
236
|
+
</label>
|
|
237
|
+
<DropdownMenu>
|
|
238
|
+
<DropdownMenuTrigger className="ml-auto inline-flex h-8 items-center gap-2 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring">
|
|
239
|
+
<Columns3 aria-hidden="true" className="size-4" />
|
|
240
|
+
{i18n.t("board.columns")}
|
|
241
|
+
</DropdownMenuTrigger>
|
|
242
|
+
<DropdownMenuContent align="end">
|
|
243
|
+
{table.getAllLeafColumns().map((column) => (
|
|
244
|
+
<DropdownMenuCheckboxItem
|
|
245
|
+
key={column.id}
|
|
246
|
+
checked={column.getIsVisible()}
|
|
247
|
+
onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
|
|
248
|
+
// The last visible column cannot be hidden: an empty table is a screen that has
|
|
249
|
+
// lost its data as far as anybody looking at it can tell.
|
|
250
|
+
disabled={column.getIsVisible() && table.getVisibleLeafColumns().length === 1}
|
|
251
|
+
>
|
|
252
|
+
{i18n.t(`board.column.${column.id === "title" ? "title" : column.id}`)}
|
|
253
|
+
</DropdownMenuCheckboxItem>
|
|
254
|
+
))}
|
|
255
|
+
</DropdownMenuContent>
|
|
256
|
+
</DropdownMenu>
|
|
257
|
+
</div>
|
|
258
|
+
<div className="min-h-0 flex-1 overflow-auto rounded-lg border">
|
|
259
|
+
<table className="w-full border-collapse text-sm">
|
|
260
|
+
<thead className="sticky top-0 z-10 bg-card">
|
|
261
|
+
{table.getHeaderGroups().map((group) => (
|
|
262
|
+
<tr key={group.id}>
|
|
263
|
+
{group.headers.map((header) => (
|
|
264
|
+
<th
|
|
265
|
+
key={header.id}
|
|
266
|
+
scope="col"
|
|
267
|
+
aria-sort={
|
|
268
|
+
header.column.getIsSorted() === "asc"
|
|
269
|
+
? "ascending"
|
|
270
|
+
: header.column.getIsSorted() === "desc"
|
|
271
|
+
? "descending"
|
|
272
|
+
: "none"
|
|
273
|
+
}
|
|
274
|
+
className="border-b px-3 py-2 text-left font-medium whitespace-nowrap"
|
|
275
|
+
>
|
|
276
|
+
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
280
|
+
className="inline-flex items-center gap-1 rounded outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
281
|
+
>
|
|
282
|
+
<table.FlexRender header={header} />
|
|
283
|
+
<span aria-hidden="true" className="text-muted-foreground">
|
|
284
|
+
{header.column.getIsSorted() === "asc"
|
|
285
|
+
? "↑"
|
|
286
|
+
: header.column.getIsSorted() === "desc"
|
|
287
|
+
? "↓"
|
|
288
|
+
: ""}
|
|
289
|
+
</span>
|
|
290
|
+
</button>
|
|
291
|
+
) : (
|
|
292
|
+
<table.FlexRender header={header} />
|
|
293
|
+
)}
|
|
294
|
+
</th>
|
|
295
|
+
))}
|
|
296
|
+
</tr>
|
|
297
|
+
))}
|
|
298
|
+
</thead>
|
|
299
|
+
<tbody>
|
|
300
|
+
{table.getRowModel().rows.map((row) => (
|
|
301
|
+
<tr key={row.id} className="border-b last:border-b-0 hover:bg-muted/60">
|
|
302
|
+
{row.getVisibleCells().map((cell) => (
|
|
303
|
+
<td key={cell.id} className="px-3 py-2 align-top">
|
|
304
|
+
{/* ⚠️ A group row carries the grouped value and NOTHING else. Left to render
|
|
305
|
+
itself, every other cell of a group row falls back to the first task in the
|
|
306
|
+
group — so the heading "Backlog 3" came with one arbitrary member's title
|
|
307
|
+
and its date beside it, which reads as a row of its own and is a lie about
|
|
308
|
+
the two below it. */}
|
|
309
|
+
{row.getIsGrouped() && !cell.getIsGrouped() ? null : cell.getIsGrouped() ? (
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={row.getToggleExpandedHandler()}
|
|
313
|
+
aria-expanded={row.getIsExpanded()}
|
|
314
|
+
className="inline-flex items-center gap-2 rounded font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
315
|
+
>
|
|
316
|
+
{row.getIsExpanded() ? (
|
|
317
|
+
<ChevronDown aria-hidden="true" className="size-4" />
|
|
318
|
+
) : (
|
|
319
|
+
<ChevronRight aria-hidden="true" className="size-4" />
|
|
320
|
+
)}
|
|
321
|
+
<GroupLabel row={row} columnId={cell.column.id} value={cell.getValue()} />
|
|
322
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
323
|
+
{row.subRows.length}
|
|
324
|
+
</span>
|
|
325
|
+
</button>
|
|
326
|
+
) : cell.getIsPlaceholder() ? null : cell.column.id === "title" ? (
|
|
327
|
+
<div
|
|
328
|
+
className="flex min-w-0 items-start gap-1.5"
|
|
329
|
+
// ⚠️ The indent is a style and not a nested table. A row nested in markup
|
|
330
|
+
// could not be sorted or grouped with its siblings, and a screen reader
|
|
331
|
+
// would read four tables where there is one.
|
|
332
|
+
style={{ paddingInlineStart: `${row.depth * 1.25}rem` }}
|
|
333
|
+
>
|
|
334
|
+
{row.getCanExpand() ? (
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
onClick={row.getToggleExpandedHandler()}
|
|
338
|
+
aria-expanded={row.getIsExpanded()}
|
|
339
|
+
aria-label={i18n.t("board.toggleSubtasks", {
|
|
340
|
+
task: row.original.title,
|
|
341
|
+
})}
|
|
342
|
+
className="mt-0.5 rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
343
|
+
>
|
|
344
|
+
{row.getIsExpanded() ? (
|
|
345
|
+
<ChevronDown aria-hidden="true" className="size-4" />
|
|
346
|
+
) : (
|
|
347
|
+
<ChevronRight aria-hidden="true" className="size-4" />
|
|
348
|
+
)}
|
|
349
|
+
</button>
|
|
350
|
+
) : (
|
|
351
|
+
<span aria-hidden="true" className="size-4 shrink-0" />
|
|
352
|
+
)}
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
onClick={() => select(row.original.id)}
|
|
356
|
+
className="min-w-0 rounded text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
357
|
+
>
|
|
358
|
+
{row.original.title}
|
|
359
|
+
</button>
|
|
360
|
+
{board.blockedBy(row.original).length > 0 ? (
|
|
361
|
+
<span className="shrink-0 rounded-md border border-destructive px-1 text-xs text-destructive">
|
|
362
|
+
{i18n.t("board.blocked")}
|
|
363
|
+
</span>
|
|
364
|
+
) : null}
|
|
365
|
+
</div>
|
|
366
|
+
) : cell.column.id === "status" ? (
|
|
367
|
+
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
|
368
|
+
<span
|
|
369
|
+
aria-hidden="true"
|
|
370
|
+
className={`size-2 rounded-full ${toneOf(row.original.status, board.statuses).dot}`}
|
|
371
|
+
/>
|
|
372
|
+
<table.FlexRender cell={cell} />
|
|
373
|
+
</span>
|
|
374
|
+
) : (
|
|
375
|
+
<table.FlexRender cell={cell} />
|
|
376
|
+
)}
|
|
377
|
+
</td>
|
|
378
|
+
))}
|
|
379
|
+
</tr>
|
|
380
|
+
))}
|
|
381
|
+
</tbody>
|
|
382
|
+
</table>
|
|
383
|
+
{table.getRowModel().rows.length === 0 ? (
|
|
384
|
+
<p className="p-6 text-sm text-muted-foreground">{i18n.t("board.noTasks")}</p>
|
|
385
|
+
) : null}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The value a group was formed on, said in words.
|
|
393
|
+
*
|
|
394
|
+
* ⚠️ The assignee column groups on a STABLE key (`user:…`, `agent:…`) and that key is not a name.
|
|
395
|
+
* The heading is therefore read off the first row of the group rather than off the key — that is
|
|
396
|
+
* the only place an agent's title can be resolved, and the only place a person who cannot be named
|
|
397
|
+
* is named honestly rather than by their id.
|
|
398
|
+
*/
|
|
399
|
+
function GroupLabel({
|
|
400
|
+
row,
|
|
401
|
+
columnId,
|
|
402
|
+
value,
|
|
403
|
+
}: {
|
|
404
|
+
row: { subRows: { original: BoardRow }[] };
|
|
405
|
+
columnId: string;
|
|
406
|
+
value: unknown;
|
|
407
|
+
}) {
|
|
408
|
+
const i18n = useI18n();
|
|
409
|
+
if (columnId !== "assignee") return <span>{String(value ?? "")}</span>;
|
|
410
|
+
const assignee = row.subRows[0]?.original.assignee ?? null;
|
|
411
|
+
if (assignee === null) return <span>{i18n.t("board.unassigned")}</span>;
|
|
412
|
+
return <BoardAssigneeLabel assignee={assignee} />;
|
|
413
|
+
}
|