@anchrd/intel-ui 0.16.1 → 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/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 +86 -1
- package/src/i18n/en.json +86 -1
- package/src/i18n/es.json +86 -1
- package/src/nodes/nodes.tsx +16 -0
- package/src/styles.css +33 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { BoardStatus, BoardTask } from "@anchrd/intel-contract";
|
|
2
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { shownTasks } from "@/board/board-data/board-data.ts";
|
|
5
|
+
import { type BoardItem, boardSchedule } from "@/board/board-items/board-items.ts";
|
|
6
|
+
import { labelOf, toneOf } from "@/board/board-status/board-status.ts";
|
|
7
|
+
import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
|
|
8
|
+
import { ItemGantt } from "@/components/ui/item-gantt.tsx";
|
|
9
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
10
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
11
|
+
import {
|
|
12
|
+
barDays,
|
|
13
|
+
dayWidth,
|
|
14
|
+
type GanttNode,
|
|
15
|
+
GanttRowHeight,
|
|
16
|
+
type GanttRow as GanttRowModel,
|
|
17
|
+
type GanttScale,
|
|
18
|
+
GanttScales,
|
|
19
|
+
ganttAxis,
|
|
20
|
+
ganttLinks,
|
|
21
|
+
ganttRows,
|
|
22
|
+
ganttTree,
|
|
23
|
+
ganttWrite,
|
|
24
|
+
linkGeometry,
|
|
25
|
+
} from "./board-gantt.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The board as a timeline, with its dependencies drawn (anchrd/intel#293).
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ It draws `boardSchedule` and nothing else, the same adapter the calendar reads. That is the ONE
|
|
31
|
+
* reading of a task as a dated thing (#286), and a Gantt that read `dueDate` for itself would be the
|
|
32
|
+
* second — the two would then disagree about the case that is actually subtle, a task with a start
|
|
33
|
+
* date and no due date, and they would disagree in a picture.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ A dependency is an arrow, and it means what the "Blocked" chip and the graph's thick edge mean:
|
|
36
|
+
* `board.blockedBy`, which is the status list's own `terminal` flag (anchrd/intel#311). Three
|
|
37
|
+
* surfaces, one definition. This screen would be the easiest place to invent a fourth, because an
|
|
38
|
+
* arrow is drawn from geometry and nothing about geometry knows what finished means.
|
|
39
|
+
*/
|
|
40
|
+
export function BoardGantt({ board, select, showArchived }: BoardViewProps) {
|
|
41
|
+
const i18n = useI18n();
|
|
42
|
+
const [scale, setScale] = useState<GanttScale>("weeks");
|
|
43
|
+
// ⚠️ Component state, not a module-level store. The Gantt from the registry kept its scroll and
|
|
44
|
+
// drag state in atoms declared at module scope, which two boards open at once would have shared —
|
|
45
|
+
// see the header of `item-gantt.tsx` for why that is worse here than it was in the calendar.
|
|
46
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
|
|
47
|
+
|
|
48
|
+
const tasks = useMemo(() => shownTasks(board.tasks, showArchived), [board.tasks, showArchived]);
|
|
49
|
+
const tree = useMemo(() => {
|
|
50
|
+
const schedule = boardSchedule(tasks, board.statuses, board.blockedBy);
|
|
51
|
+
const items = new Map<string, BoardItem>(schedule.items.map((item) => [item.id, item]));
|
|
52
|
+
return ganttTree(tasks, items);
|
|
53
|
+
}, [tasks, board.statuses, board.blockedBy]);
|
|
54
|
+
|
|
55
|
+
const rows = useMemo(() => ganttRows(tree.roots, collapsed), [tree.roots, collapsed]);
|
|
56
|
+
const links = useMemo(() => ganttLinks(rows, board.blockedBy), [rows, board.blockedBy]);
|
|
57
|
+
|
|
58
|
+
const width = dayWidth(scale);
|
|
59
|
+
const axis = useMemo(
|
|
60
|
+
() =>
|
|
61
|
+
ganttAxis(
|
|
62
|
+
rows.flatMap((row) => (row.bar === null ? [] : [row.bar])),
|
|
63
|
+
new Date(),
|
|
64
|
+
scale,
|
|
65
|
+
i18n.locale,
|
|
66
|
+
),
|
|
67
|
+
[rows, scale, i18n.locale],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const chartRows = rows.map((row) => ({
|
|
71
|
+
id: row.task.id,
|
|
72
|
+
sidebar: (
|
|
73
|
+
<SidebarRow
|
|
74
|
+
row={row}
|
|
75
|
+
i18n={i18n}
|
|
76
|
+
statuses={board.statuses}
|
|
77
|
+
blockedBy={board.blockedBy}
|
|
78
|
+
onToggle={() =>
|
|
79
|
+
setCollapsed((current) => {
|
|
80
|
+
const next = new Set(current);
|
|
81
|
+
if (next.has(row.task.id)) next.delete(row.task.id);
|
|
82
|
+
else next.add(row.task.id);
|
|
83
|
+
return next;
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
onOpen={() => select(row.task.id)}
|
|
87
|
+
/>
|
|
88
|
+
),
|
|
89
|
+
bar:
|
|
90
|
+
row.bar === null
|
|
91
|
+
? null
|
|
92
|
+
: {
|
|
93
|
+
...barDays(row.bar, axis.start),
|
|
94
|
+
className: toneOf(row.task.status, board.statuses).dot,
|
|
95
|
+
derived: row.bar.derived,
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 p-6">
|
|
101
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
102
|
+
<label className="flex items-center gap-2 text-sm">
|
|
103
|
+
<span className="text-muted-foreground">{i18n.t("board.gantt.scale")}</span>
|
|
104
|
+
<select
|
|
105
|
+
value={scale}
|
|
106
|
+
onChange={(event) => setScale(event.currentTarget.value as GanttScale)}
|
|
107
|
+
className="h-8 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
108
|
+
>
|
|
109
|
+
{GanttScales.map((option) => (
|
|
110
|
+
<option key={option} value={option}>
|
|
111
|
+
{i18n.t(`board.gantt.scale.${option}`)}
|
|
112
|
+
</option>
|
|
113
|
+
))}
|
|
114
|
+
</select>
|
|
115
|
+
</label>
|
|
116
|
+
{links.hidden > 0 ? (
|
|
117
|
+
<p className="text-sm text-muted-foreground">
|
|
118
|
+
{i18n.t("board.gantt.hiddenLinks", { count: links.hidden })}
|
|
119
|
+
</p>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{rows.length === 0 ? (
|
|
124
|
+
<p className="text-sm text-muted-foreground">{i18n.t("board.gantt.nothingDated")}</p>
|
|
125
|
+
) : (
|
|
126
|
+
<ItemGantt
|
|
127
|
+
rows={chartRows}
|
|
128
|
+
links={linkGeometry(links.links, rows, axis.start, width, GanttRowHeight)}
|
|
129
|
+
groups={axis.groups}
|
|
130
|
+
ticks={axis.ticks}
|
|
131
|
+
days={axis.days}
|
|
132
|
+
dayWidth={width}
|
|
133
|
+
rowHeight={GanttRowHeight}
|
|
134
|
+
today={axis.today}
|
|
135
|
+
labels={{
|
|
136
|
+
today: i18n.t("board.gantt.today"),
|
|
137
|
+
move: (id) => i18n.t("board.gantt.move", { task: titleOf(id, rows) }),
|
|
138
|
+
start: (id) => i18n.t("board.gantt.resizeStart", { task: titleOf(id, rows) }),
|
|
139
|
+
end: (id) => i18n.t("board.gantt.resizeEnd", { task: titleOf(id, rows) }),
|
|
140
|
+
}}
|
|
141
|
+
/**
|
|
142
|
+
* ⚠️ ONE `board_task_update` for a whole gesture, and none at all for a gesture that
|
|
143
|
+
* changed no date. `ganttWrite` answers `null` for a bar put back where it was picked up;
|
|
144
|
+
* sent anyway it would mint a version and stand in the audit as work that did not happen.
|
|
145
|
+
*/
|
|
146
|
+
onEdit={(taskId, edit, deltaDays) => {
|
|
147
|
+
const row = rows.find((entry) => entry.task.id === taskId);
|
|
148
|
+
// A derived bar has no `item`, which is the same reason it has no grips: one gesture
|
|
149
|
+
// cannot write the five tasks it covers.
|
|
150
|
+
if (row === undefined || row.item === null) return;
|
|
151
|
+
const write = ganttWrite(row.task, row.item, edit, deltaDays);
|
|
152
|
+
if (write) board.update.mutate(write);
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* ⚠️ The leftover column, and the DoD asks for it by name: a task with no date must not
|
|
158
|
+
vanish because a timeline has nowhere to put it. Whole subtrees, indented as they stand on
|
|
159
|
+
the board — a dateless epic with two dateless children is three tasks, and listing only the
|
|
160
|
+
epic would lose the other two just as quietly.
|
|
161
|
+
|
|
162
|
+
⚠️ Nothing here is a drop target. The kanban learned that a lane which accepts a drop the
|
|
163
|
+
server refuses is worse than one that cannot be dropped on at all; the reason here is a
|
|
164
|
+
different one and it is the reason there is no drag between the two lists in either
|
|
165
|
+
direction. Dragging a bar into this list would mean CLEARING both dates, and a gesture that
|
|
166
|
+
deletes what somebody typed is not a gesture — it is the delete button, which is in the
|
|
167
|
+
panel and asks first. Dates are given in the panel too. */}
|
|
168
|
+
{tree.undated.length === 0 ? null : (
|
|
169
|
+
<section
|
|
170
|
+
aria-label={i18n.t("board.gantt.undated")}
|
|
171
|
+
className="max-h-48 shrink-0 overflow-y-auto rounded-lg border border-dashed bg-muted/30 p-3"
|
|
172
|
+
>
|
|
173
|
+
<h3 className="pb-1 text-xs font-medium text-muted-foreground">
|
|
174
|
+
{i18n.t("board.gantt.undatedCount", { count: countTasks(tree.undated) })}
|
|
175
|
+
</h3>
|
|
176
|
+
<ul className="flex flex-col">
|
|
177
|
+
{ganttRows(tree.undated, new Set()).map((row) => (
|
|
178
|
+
<li key={row.task.id} style={{ paddingInlineStart: `${row.depth * 1}rem` }}>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={() => select(row.task.id)}
|
|
182
|
+
className="inline-flex items-center gap-1.5 rounded py-0.5 text-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
183
|
+
>
|
|
184
|
+
<span
|
|
185
|
+
aria-hidden="true"
|
|
186
|
+
className={`size-2 shrink-0 rounded-full ${toneOf(row.task.status, board.statuses).dot}`}
|
|
187
|
+
/>
|
|
188
|
+
<span className="min-w-0 truncate">{row.task.title}</span>
|
|
189
|
+
<span className="sr-only">{labelOf(row.task.status, board.statuses)}</span>
|
|
190
|
+
</button>
|
|
191
|
+
</li>
|
|
192
|
+
))}
|
|
193
|
+
</ul>
|
|
194
|
+
</section>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function titleOf(taskId: string, rows: GanttRowModel[]): string {
|
|
201
|
+
return rows.find((row) => row.task.id === taskId)?.task.title ?? taskId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function countTasks(nodes: GanttNode[]): number {
|
|
205
|
+
return nodes.reduce((total, node) => total + 1 + countTasks(node.children), 0);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* One row's left column.
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ Everything the bar and the arrows show is here in words as well. The chart is a picture, and a
|
|
212
|
+
* picture is the one thing a reader who cannot see it gets nothing from: the status, the dates and
|
|
213
|
+
* what the task waits for are read out here, so the arrows are a second way of saying something
|
|
214
|
+
* rather than the only way.
|
|
215
|
+
*/
|
|
216
|
+
function SidebarRow({
|
|
217
|
+
row,
|
|
218
|
+
i18n,
|
|
219
|
+
statuses,
|
|
220
|
+
blockedBy,
|
|
221
|
+
onToggle,
|
|
222
|
+
onOpen,
|
|
223
|
+
}: {
|
|
224
|
+
row: GanttRowModel;
|
|
225
|
+
i18n: I18n;
|
|
226
|
+
statuses: BoardStatus[];
|
|
227
|
+
blockedBy(task: BoardTask): BoardTask[];
|
|
228
|
+
onToggle(): void;
|
|
229
|
+
onOpen(): void;
|
|
230
|
+
}) {
|
|
231
|
+
const open = blockedBy(row.task);
|
|
232
|
+
const span = row.bar;
|
|
233
|
+
const date = (value: Date) => value.toLocaleDateString(i18n.locale);
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
<span style={{ width: `${row.depth * 0.75}rem` }} aria-hidden="true" className="shrink-0" />
|
|
237
|
+
{row.hasChildren ? (
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={onToggle}
|
|
241
|
+
aria-expanded={!row.collapsed}
|
|
242
|
+
aria-label={i18n.t("board.toggleSubtasks", { task: row.task.title })}
|
|
243
|
+
className="shrink-0 rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
244
|
+
>
|
|
245
|
+
{row.collapsed ? (
|
|
246
|
+
<ChevronRight aria-hidden="true" className="size-4" />
|
|
247
|
+
) : (
|
|
248
|
+
<ChevronDown aria-hidden="true" className="size-4" />
|
|
249
|
+
)}
|
|
250
|
+
</button>
|
|
251
|
+
) : (
|
|
252
|
+
<span aria-hidden="true" className="size-4 shrink-0" />
|
|
253
|
+
)}
|
|
254
|
+
<span
|
|
255
|
+
aria-hidden="true"
|
|
256
|
+
className={`size-2 shrink-0 rounded-full ${toneOf(row.task.status, statuses).dot}`}
|
|
257
|
+
/>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={onOpen}
|
|
261
|
+
className="min-w-0 flex-1 truncate rounded text-left text-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
262
|
+
>
|
|
263
|
+
{row.task.title}
|
|
264
|
+
<span className="sr-only">
|
|
265
|
+
{` — ${labelOf(row.task.status, statuses)}`}
|
|
266
|
+
{span === null
|
|
267
|
+
? ""
|
|
268
|
+
: ` — ${i18n.t("board.gantt.spanLabel", {
|
|
269
|
+
start: date(span.startAt),
|
|
270
|
+
end: date(span.endAt),
|
|
271
|
+
})}`}
|
|
272
|
+
{open.length === 0
|
|
273
|
+
? ""
|
|
274
|
+
: ` — ${i18n.t("board.gantt.waitsFor", {
|
|
275
|
+
tasks: open.map((task) => task.title).join(", "),
|
|
276
|
+
})}`}
|
|
277
|
+
</span>
|
|
278
|
+
</button>
|
|
279
|
+
{open.length > 0 ? (
|
|
280
|
+
<span className="shrink-0 rounded-md border border-destructive px-1 text-xs text-destructive">
|
|
281
|
+
{i18n.t("board.blocked")}
|
|
282
|
+
</span>
|
|
283
|
+
) : null}
|
|
284
|
+
</>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { BoardStatus, BoardTask } from "@anchrd/intel-contract";
|
|
2
|
+
import { MultiDirectedGraph } from "graphology";
|
|
3
|
+
import { toneOf } from "@/board/board-status/board-status.ts";
|
|
4
|
+
import { resolveGraphColor } from "@/node-graph/node-graph.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The two things a board edge can be, and they must never look the same.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ `parentId` and `dependsOn` are different relations with different rules — one is the
|
|
10
|
+
* hierarchy and is checked for cycles on its own, the other is an order of work and is checked
|
|
11
|
+
* separately, and a subtask waiting on its own epic is legal precisely because they are not one
|
|
12
|
+
* graph (#285). Drawn identically, the picture would claim they are, and the one question a board
|
|
13
|
+
* graph exists to answer — what is waiting on what — would be unreadable.
|
|
14
|
+
*/
|
|
15
|
+
export type BoardEdgeKind = "parent" | "depends";
|
|
16
|
+
|
|
17
|
+
// The task and everything under it. `null` is the whole board.
|
|
18
|
+
export function subtreeOf(tasks: BoardTask[], rootId: string | null): BoardTask[] {
|
|
19
|
+
if (rootId === null) return tasks;
|
|
20
|
+
const kept = new Set([rootId]);
|
|
21
|
+
// Bounded by the contract's maximum depth of five, so a sweep per level is cheap and needs no
|
|
22
|
+
// ordering assumptions about the list.
|
|
23
|
+
for (let grew = true; grew; ) {
|
|
24
|
+
grew = false;
|
|
25
|
+
for (const task of tasks) {
|
|
26
|
+
if (task.parentId !== null && kept.has(task.parentId) && !kept.has(task.id)) {
|
|
27
|
+
kept.add(task.id);
|
|
28
|
+
grew = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return tasks.filter((task) => kept.has(task.id));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// How deep a task sits, for the layout. Counted against the tasks actually drawn, so a subtree
|
|
36
|
+
// filter puts its own root on the top row instead of indenting it by where it came from.
|
|
37
|
+
function depthsOf(tasks: BoardTask[]): Map<string, number> {
|
|
38
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
39
|
+
const depths = new Map<string, number>();
|
|
40
|
+
const depthOf = (task: BoardTask, seen: Set<string>): number => {
|
|
41
|
+
const known = depths.get(task.id);
|
|
42
|
+
if (known !== undefined) return known;
|
|
43
|
+
const parent = task.parentId === null ? undefined : byId.get(task.parentId);
|
|
44
|
+
// A cycle cannot be written (#285); if one ever were, this stops rather than recursing.
|
|
45
|
+
const depth = parent === undefined || seen.has(parent.id) ? 0 : depthOf(parent, seen) + 1;
|
|
46
|
+
seen.add(task.id);
|
|
47
|
+
depths.set(task.id, depth);
|
|
48
|
+
return depth;
|
|
49
|
+
};
|
|
50
|
+
for (const task of tasks) depthOf(task, new Set([task.id]));
|
|
51
|
+
return depths;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BoardGraphLabels {
|
|
55
|
+
parent: string;
|
|
56
|
+
depends: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The board as Sigma draws it (anchrd/intel#286).
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ Laid out by depth rather than on a spiral like the relation graph. A board's edges are mostly
|
|
63
|
+
* `parentId`, and a spiral scatters a parent and its children across the circle — the hierarchy is
|
|
64
|
+
* then present in the data and invisible in the picture. Rows by depth put it back, and the
|
|
65
|
+
* dependency edges become what they are: the lines that cut across.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ An edge is dropped when either end is not drawn, never drawn to an invented node. Under a
|
|
68
|
+
* subtree filter a dependency regularly points outside the subtree, and a placeholder for it would
|
|
69
|
+
* put a task on screen that the filter was asked to leave off.
|
|
70
|
+
*/
|
|
71
|
+
export function createBoardGraph(
|
|
72
|
+
tasks: BoardTask[],
|
|
73
|
+
statuses: BoardStatus[],
|
|
74
|
+
blockedIds: ReadonlySet<string>,
|
|
75
|
+
labels: BoardGraphLabels,
|
|
76
|
+
): MultiDirectedGraph {
|
|
77
|
+
const graph = new MultiDirectedGraph();
|
|
78
|
+
const structure = resolveGraphColor("--border", "--muted-foreground");
|
|
79
|
+
const dependency = resolveGraphColor("--destructive", "--foreground");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* ⚠️ One node per id, however often the list names it (anchrd/intel#321).
|
|
83
|
+
*
|
|
84
|
+
* `graph.addNode` refuses a repeated key the way `addDirectedEdgeWithKey` does — and it runs
|
|
85
|
+
* first, so a board carrying two tasks with one id threw `UsageGraphError` before a single edge
|
|
86
|
+
* was reached and took this view off the screen for everybody looking at that board.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ Through the app nothing arrives here twice any more: the bundle import refuses such a
|
|
89
|
+
* document, and `orderedTasks` folds a stored one before any view sees it. It stays because this
|
|
90
|
+
* function is EXPORTED and takes whatever list it is handed — a view may not be the thing that
|
|
91
|
+
* turns odd data into a blank screen, the same reason the repeated dependency below is folded
|
|
92
|
+
* rather than trusted (anchrd/intel#318). The FIRST mention wins, which is the entry
|
|
93
|
+
* `orderedTasks` keeps, so the graph and the lanes cannot disagree about which of the pair is on
|
|
94
|
+
* the board.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ Folded here rather than skipped at `addNode`, because the row widths below are counted from
|
|
97
|
+
* this list too. A repeat that was merely not drawn would leave its level one card wider than it
|
|
98
|
+
* is, and every node on that level would sit off the axis the layout centres on.
|
|
99
|
+
*/
|
|
100
|
+
const drawn = new Set<string>();
|
|
101
|
+
const nodes = tasks.filter((task) => {
|
|
102
|
+
if (drawn.has(task.id)) return false;
|
|
103
|
+
drawn.add(task.id);
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
const depths = depthsOf(nodes);
|
|
107
|
+
|
|
108
|
+
// ⚠️ Counted first, placed second, so every row can be CENTRED. Laid out left to right from zero,
|
|
109
|
+
// a level of six and a level of one form a wedge that reads as a hierarchy which is not there —
|
|
110
|
+
// and Sigma rescales to the bounding box, so one wide level squashes every other one flat.
|
|
111
|
+
const widths = new Map<number, number>();
|
|
112
|
+
for (const task of nodes) {
|
|
113
|
+
const depth = depths.get(task.id) ?? 0;
|
|
114
|
+
widths.set(depth, (widths.get(depth) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
const placed = new Map<number, number>();
|
|
117
|
+
|
|
118
|
+
for (const task of nodes) {
|
|
119
|
+
const depth = depths.get(task.id) ?? 0;
|
|
120
|
+
const column = placed.get(depth) ?? 0;
|
|
121
|
+
placed.set(depth, column + 1);
|
|
122
|
+
graph.addNode(task.id, {
|
|
123
|
+
label: task.title,
|
|
124
|
+
x: (column - ((widths.get(depth) ?? 1) - 1) / 2) * 2.2,
|
|
125
|
+
// Down the screen as depth grows, which is the direction a hierarchy is read in.
|
|
126
|
+
y: -depth * 2.4,
|
|
127
|
+
// A blocked task is bigger as well as ringed by its edges, so it is findable in a graph one
|
|
128
|
+
// is looking at from far enough away that the incoming arrows are a tangle.
|
|
129
|
+
size: blockedIds.has(task.id) ? 9 : 7,
|
|
130
|
+
// ⚠️ The status, resolved from the SAME ramp the lanes and the calendar use. Sigma paints to
|
|
131
|
+
// a canvas and cannot read a class, so the token is turned into sRGB here — and where a
|
|
132
|
+
// theme has no such token, `undefined` leaves Sigma its own default rather than putting an
|
|
133
|
+
// Anchrd colour into a customer's board.
|
|
134
|
+
color: resolveGraphColor(toneOf(task.status, statuses).token, "--foreground"),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const task of nodes) {
|
|
139
|
+
if (task.parentId !== null && drawn.has(task.parentId)) {
|
|
140
|
+
graph.addDirectedEdgeWithKey(`parent:${task.id}`, task.parentId, task.id, {
|
|
141
|
+
label: labels.parent,
|
|
142
|
+
// Thin, unlabelled by an arrowhead, in the border colour: structure is the paper the work
|
|
143
|
+
// is drawn on, not a step in it.
|
|
144
|
+
type: "line",
|
|
145
|
+
size: 1,
|
|
146
|
+
...(structure ? { color: structure } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* ⚠️ One arrow per pair, however often the list names it (anchrd/intel#318).
|
|
151
|
+
*
|
|
152
|
+
* The key is minted from the pair and graphology REFUSES a second one, so a task naming the
|
|
153
|
+
* same dependency twice threw `UsageGraphError` out of this loop and took the graph view of
|
|
154
|
+
* that board off the screen — for everybody looking at it, not only for whoever wrote it. The
|
|
155
|
+
* write path refuses a repeat now and a stored one is folded away on read, so nothing arriving
|
|
156
|
+
* through Intel carries one; this stays because a view may not be the thing that turns odd data
|
|
157
|
+
* into a blank screen, and because the same reading is already in `ganttLinks` (#293): waiting
|
|
158
|
+
* twice for a task is waiting for it once, and the second arrow would draw nothing extra.
|
|
159
|
+
*/
|
|
160
|
+
const drawnDependencies = new Set<string>();
|
|
161
|
+
for (const dependency_ of task.dependsOn) {
|
|
162
|
+
if (!drawn.has(dependency_) || drawnDependencies.has(dependency_)) continue;
|
|
163
|
+
drawnDependencies.add(dependency_);
|
|
164
|
+
// From what is waited FOR to what waits, so the arrows run the way the work does.
|
|
165
|
+
graph.addDirectedEdgeWithKey(`depends:${task.id}:${dependency_}`, dependency_, task.id, {
|
|
166
|
+
label: labels.depends,
|
|
167
|
+
type: "arrow",
|
|
168
|
+
size: 2.5,
|
|
169
|
+
...(dependency ? { color: dependency } : {}),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return graph;
|
|
174
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ControlsContainer, SigmaContainer, useCamera, useRegisterEvents } from "@react-sigma/core";
|
|
2
|
+
import { LocateFixed, Minus, Plus } from "lucide-react";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { EdgeArrowProgram, EdgeRectangleProgram } from "sigma/rendering";
|
|
5
|
+
import { shownTasks } from "@/board/board-data/board-data.ts";
|
|
6
|
+
import { toneOf } from "@/board/board-status/board-status.ts";
|
|
7
|
+
import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
|
|
8
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
9
|
+
import { resolveGraphColor } from "@/node-graph/node-graph.ts";
|
|
10
|
+
import { createBoardGraph, subtreeOf } from "./board-graph.ts";
|
|
11
|
+
|
|
12
|
+
function GraphEvents({ select }: { select(taskId: string): void }) {
|
|
13
|
+
const registerEvents = useRegisterEvents();
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
registerEvents({ clickNode: ({ node }) => select(node) });
|
|
16
|
+
}, [registerEvents, select]);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function GraphControls({ labels }: { labels: { in: string; out: string; fit: string } }) {
|
|
21
|
+
const camera = useCamera({ duration: 180, factor: 1.5 });
|
|
22
|
+
const controls = [
|
|
23
|
+
{ label: labels.in, action: camera.zoomIn, icon: Plus },
|
|
24
|
+
{ label: labels.out, action: camera.zoomOut, icon: Minus },
|
|
25
|
+
{ label: labels.fit, action: camera.reset, icon: LocateFixed },
|
|
26
|
+
];
|
|
27
|
+
return (
|
|
28
|
+
<ControlsContainer className="!m-4 flex overflow-hidden rounded-lg border bg-card shadow-lg">
|
|
29
|
+
{controls.map(({ label, action, icon: Icon }) => (
|
|
30
|
+
<button
|
|
31
|
+
key={label}
|
|
32
|
+
type="button"
|
|
33
|
+
aria-label={label}
|
|
34
|
+
onClick={() => action()}
|
|
35
|
+
className="grid size-9 place-items-center border-r text-card-foreground outline-none last:border-r-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
36
|
+
>
|
|
37
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
38
|
+
</button>
|
|
39
|
+
))}
|
|
40
|
+
</ControlsContainer>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The board as a graph (anchrd/intel#286).
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ On the Sigma stack the relation graph already stands on — same container, same controls, same
|
|
48
|
+
* token-resolving colour helper. A second renderer for a second kind of graph would be two things
|
|
49
|
+
* to keep looking like Intel, and the first customer theme would separate them.
|
|
50
|
+
*/
|
|
51
|
+
export function BoardGraph({ board, select, showArchived }: BoardViewProps) {
|
|
52
|
+
const i18n = useI18n();
|
|
53
|
+
const [root, setRoot] = useState<string>("");
|
|
54
|
+
|
|
55
|
+
const tasks = useMemo(() => shownTasks(board.tasks, showArchived), [board.tasks, showArchived]);
|
|
56
|
+
// Only tasks that have something under them are worth offering: filtering to a leaf draws one
|
|
57
|
+
// node, which is a picture of nothing.
|
|
58
|
+
const roots = useMemo(
|
|
59
|
+
() => tasks.filter((task) => (board.childrenOf.get(task.id) ?? []).length > 0),
|
|
60
|
+
[tasks, board.childrenOf],
|
|
61
|
+
);
|
|
62
|
+
const drawn = useMemo(
|
|
63
|
+
() => subtreeOf(tasks, root === "" || !tasks.some((task) => task.id === root) ? null : root),
|
|
64
|
+
[tasks, root],
|
|
65
|
+
);
|
|
66
|
+
const blockedIds = useMemo(
|
|
67
|
+
() => new Set(drawn.filter((task) => board.blockedBy(task).length > 0).map((task) => task.id)),
|
|
68
|
+
[drawn, board.blockedBy],
|
|
69
|
+
);
|
|
70
|
+
const graph = useMemo(
|
|
71
|
+
() =>
|
|
72
|
+
createBoardGraph(drawn, board.statuses, blockedIds, {
|
|
73
|
+
parent: i18n.t("board.graph.parentEdge"),
|
|
74
|
+
depends: i18n.t("board.graph.dependsEdge"),
|
|
75
|
+
}),
|
|
76
|
+
[drawn, board.statuses, blockedIds, i18n],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const label = resolveGraphColor("--foreground");
|
|
80
|
+
const edgeLabel = resolveGraphColor("--muted-foreground", "--foreground");
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<section aria-label={i18n.t("board.view.graph")} className="flex min-h-0 flex-1 flex-col">
|
|
84
|
+
<div className="flex flex-wrap items-center gap-4 border-b px-6 py-3">
|
|
85
|
+
<label className="flex items-center gap-2 text-sm">
|
|
86
|
+
<span className="text-muted-foreground">{i18n.t("board.graph.subtree")}</span>
|
|
87
|
+
<select
|
|
88
|
+
value={root}
|
|
89
|
+
onChange={(event) => setRoot(event.currentTarget.value)}
|
|
90
|
+
className="h-8 max-w-56 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
91
|
+
>
|
|
92
|
+
<option value="">{i18n.t("board.graph.wholeBoard")}</option>
|
|
93
|
+
{roots.map((task) => (
|
|
94
|
+
<option key={task.id} value={task.id}>
|
|
95
|
+
{task.title}
|
|
96
|
+
</option>
|
|
97
|
+
))}
|
|
98
|
+
</select>
|
|
99
|
+
</label>
|
|
100
|
+
{/* ⚠️ The legend is not decoration: it is what turns a colour into a status and a line into
|
|
101
|
+
a relation for a reader who has not been told. Both edge kinds are in it, because
|
|
102
|
+
telling them apart is the whole reason there are two. */}
|
|
103
|
+
<ul className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
|
104
|
+
{board.statuses.map((status) => (
|
|
105
|
+
<li key={status.id} className="flex items-center gap-1.5">
|
|
106
|
+
<span
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
className={`size-2 rounded-full ${toneOf(status.id, board.statuses).dot}`}
|
|
109
|
+
/>
|
|
110
|
+
{status.label}
|
|
111
|
+
</li>
|
|
112
|
+
))}
|
|
113
|
+
<li className="flex items-center gap-1.5">
|
|
114
|
+
<span aria-hidden="true" className="h-px w-5 bg-border" />
|
|
115
|
+
{i18n.t("board.graph.parentEdge")}
|
|
116
|
+
</li>
|
|
117
|
+
<li className="flex items-center gap-1.5">
|
|
118
|
+
<span aria-hidden="true" className="h-0.5 w-5 bg-destructive" />
|
|
119
|
+
{i18n.t("board.graph.dependsEdge")}
|
|
120
|
+
</li>
|
|
121
|
+
</ul>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="min-h-0 flex-1 bg-muted/20">
|
|
124
|
+
{drawn.length === 0 ? (
|
|
125
|
+
<div className="grid h-full place-items-center p-8 text-sm text-muted-foreground">
|
|
126
|
+
{i18n.t("board.noTasks")}
|
|
127
|
+
</div>
|
|
128
|
+
) : (
|
|
129
|
+
<SigmaContainer
|
|
130
|
+
// ⚠️ No `key`, deliberately. `SigmaContainer` rebuilds its instance whenever the `graph`
|
|
131
|
+
// prop changes and carries the previous camera state across while doing it, so a new
|
|
132
|
+
// graph is all a changed subtree filter needs — the same way the relation graph passes
|
|
133
|
+
// it (`node-graph.tsx`). A key would remount the component instead, which skips that
|
|
134
|
+
// restore and throws away the reader's zoom and pan on every added or deleted task.
|
|
135
|
+
graph={graph}
|
|
136
|
+
className="h-full w-full"
|
|
137
|
+
settings={{
|
|
138
|
+
labelRenderedSizeThreshold: 4,
|
|
139
|
+
renderEdgeLabels: true,
|
|
140
|
+
edgeLabelSize: 10,
|
|
141
|
+
// ⚠️ Sigma fits the NODES to the canvas and knows nothing about how wide their labels
|
|
142
|
+
// are, so the outermost task of a level always has its name half outside the frame.
|
|
143
|
+
// The padding is what the labels stand in.
|
|
144
|
+
stagePadding: 90,
|
|
145
|
+
// ⚠️ Registered by name rather than left to the defaults: the two edge kinds are told
|
|
146
|
+
// apart by whether they carry an arrowhead, and an unregistered `type` silently falls
|
|
147
|
+
// back to the default program — which would draw both of them the same and undo the
|
|
148
|
+
// one thing this graph has to get right.
|
|
149
|
+
edgeProgramClasses: { line: EdgeRectangleProgram, arrow: EdgeArrowProgram },
|
|
150
|
+
defaultEdgeType: "line",
|
|
151
|
+
...(label ? { labelColor: { color: label } } : {}),
|
|
152
|
+
...(edgeLabel ? { edgeLabelColor: { color: edgeLabel } } : {}),
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<GraphEvents select={select} />
|
|
156
|
+
<GraphControls
|
|
157
|
+
labels={{
|
|
158
|
+
in: i18n.t("node.graphZoomIn"),
|
|
159
|
+
out: i18n.t("node.graphZoomOut"),
|
|
160
|
+
fit: i18n.t("node.graphReset"),
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
</SigmaContainer>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</section>
|
|
167
|
+
);
|
|
168
|
+
}
|