@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,629 @@
|
|
|
1
|
+
import { ArchivedBoardStatusId, type BoardTask } from "@anchrd/intel-contract";
|
|
2
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { ExternalLink, Plus, Trash2, X } from "lucide-react";
|
|
4
|
+
import { useId, useState } from "react";
|
|
5
|
+
import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
|
|
6
|
+
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
7
|
+
import { labelOf, toneOf } from "@/board/board-status/board-status.ts";
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
} from "@/components/ui/dialog";
|
|
16
|
+
import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
|
|
17
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
18
|
+
import { useSessionUser } from "@/user-name/user-name.ts";
|
|
19
|
+
|
|
20
|
+
// The task and everything under it — the number a delete has to name BEFORE it is confirmed.
|
|
21
|
+
// ⚠️ The same rule the server applies, read off the same field: deleting cascades down `parentId`
|
|
22
|
+
// and answers with a count (#285). That count arrives only WITH the answer, which is too late to
|
|
23
|
+
// ask with — so the question is asked with this, and the server's own number is reported afterwards
|
|
24
|
+
// by the screen (`board.tsx`), which is still there when it arrives.
|
|
25
|
+
export function subtreeSize(taskId: string, childrenOf: Map<string, BoardTask[]>): number {
|
|
26
|
+
let total = 1;
|
|
27
|
+
for (const child of childrenOf.get(taskId) ?? []) total += subtreeSize(child.id, childrenOf);
|
|
28
|
+
return total;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one detail panel (anchrd/intel#286).
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ One panel for every view. A card in a lane, a row in the table, an entry in the month and
|
|
35
|
+
* a node in the graph open THIS — not four panels that agree today. Every field is a control
|
|
36
|
+
* rather than a form with a save button, because a board is edited one field at a time and each of
|
|
37
|
+
* these is one operation on the server (#285).
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ Where a task SITS is a move and everything else is an update, and that split is the server's,
|
|
40
|
+
* not a style choice here: a move mints an order key and re-checks two cycle rules, and folding it
|
|
41
|
+
* into the field editor would make every typed character pay for them.
|
|
42
|
+
*/
|
|
43
|
+
export function BoardDetail({
|
|
44
|
+
board,
|
|
45
|
+
task,
|
|
46
|
+
close,
|
|
47
|
+
select,
|
|
48
|
+
}: {
|
|
49
|
+
board: BoardHandle;
|
|
50
|
+
task: BoardTask;
|
|
51
|
+
close(): void;
|
|
52
|
+
select(taskId: string): void;
|
|
53
|
+
}) {
|
|
54
|
+
const i18n = useI18n();
|
|
55
|
+
const navigate = useNavigate();
|
|
56
|
+
const me = useSessionUser();
|
|
57
|
+
const headingId = useId();
|
|
58
|
+
// ⚠️ Four drafts, seeded ONCE. The panel is keyed on the task id (`board.tsx`), so pointing it at
|
|
59
|
+
// another card remounts it and re-seeds them — an effect on the server's values would do nothing
|
|
60
|
+
// for that and one thing besides: reset every draft whenever ANY of them round-trips, throwing
|
|
61
|
+
// away the description somebody was halfway through because the title above it saved.
|
|
62
|
+
const [title, setTitle] = useState(task.title);
|
|
63
|
+
const [description, setDescription] = useState(task.description);
|
|
64
|
+
const [startDate, setStartDate] = useState(task.startDate ?? "");
|
|
65
|
+
const [dueDate, setDueDate] = useState(task.dueDate ?? "");
|
|
66
|
+
const [newLabel, setNewLabel] = useState("");
|
|
67
|
+
const [newSubtask, setNewSubtask] = useState("");
|
|
68
|
+
/**
|
|
69
|
+
* Which drafts the reader has actually typed in.
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ Without this, leaving a field is enough to write it back. The blur guard compares the draft
|
|
72
|
+
* against the CURRENT server value, and that value moves underneath an open panel — TanStack
|
|
73
|
+
* refetches on window focus, and a refused write invalidates. So: an agent renames the task over
|
|
74
|
+
* MCP, the reader tabs through the title field without touching it, and the blur sees "draft ≠
|
|
75
|
+
* server" and puts the old title back. The draft has to say whether it is a change at all.
|
|
76
|
+
*/
|
|
77
|
+
const [edited, setEdited] = useState<Record<string, boolean>>({});
|
|
78
|
+
const edit = (field: string) => setEdited((current) => ({ ...current, [field]: true }));
|
|
79
|
+
const settle = (field: string) => setEdited((current) => ({ ...current, [field]: false }));
|
|
80
|
+
const [addingReference, setAddingReference] = useState(false);
|
|
81
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
82
|
+
|
|
83
|
+
const children = board.childrenOf.get(task.id) ?? [];
|
|
84
|
+
/**
|
|
85
|
+
* ⚠️ Only a refusal about THIS task.
|
|
86
|
+
*
|
|
87
|
+
* The mutations live in `useBoard` and are shared by the whole screen — the top bar's create form
|
|
88
|
+
* is `board.add`, and a drag in a lane is `board.move`. Read as a plain `isError`, a failed
|
|
89
|
+
* "new task" up there would pin a red `role="alert"` inside every detail panel opened afterwards,
|
|
90
|
+
* about something that never concerned the task being looked at. `variables` is what the last
|
|
91
|
+
* call was made with, so the refusal can be attributed instead of assumed.
|
|
92
|
+
*/
|
|
93
|
+
const failed =
|
|
94
|
+
[board.update, board.move, board.remove].some(
|
|
95
|
+
(mutation) => mutation.isError && mutation.variables?.taskId === task.id,
|
|
96
|
+
) ||
|
|
97
|
+
// ⚠️ `add` belongs here too, and it is the one that needed reporting most. The subtask form
|
|
98
|
+
// below is the only place in the product that can hit `BoardMaxTaskDepth` — a fifth level is
|
|
99
|
+
// refused — and the form used to clear its field either way, so the refusal looked like a
|
|
100
|
+
// subtask that had been created and then vanished. Attributed by `parentId`, which is what
|
|
101
|
+
// separates this form's writes from the "new task" box in the screen's own bar.
|
|
102
|
+
(board.add.isError && board.add.variables?.parentId === task.id);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<aside
|
|
106
|
+
aria-labelledby={headingId}
|
|
107
|
+
className="absolute top-0 right-0 bottom-0 z-20 flex w-96 flex-col overflow-y-auto border-l bg-card shadow-xl"
|
|
108
|
+
>
|
|
109
|
+
<div className="flex items-start justify-between gap-3 border-b p-4">
|
|
110
|
+
<h3 id={headingId} className="text-sm font-semibold">
|
|
111
|
+
{i18n.t("board.detail")}
|
|
112
|
+
</h3>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={close}
|
|
116
|
+
aria-label={i18n.t("common.close")}
|
|
117
|
+
className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
118
|
+
>
|
|
119
|
+
<X aria-hidden="true" className="size-4" />
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{failed ? (
|
|
124
|
+
<p role="alert" className="mx-4 mt-4 text-sm text-destructive">
|
|
125
|
+
{i18n.t("node.operationFailed")}
|
|
126
|
+
</p>
|
|
127
|
+
) : null}
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-col gap-5 p-4">
|
|
130
|
+
<Field label={i18n.t("common.title")}>
|
|
131
|
+
<input
|
|
132
|
+
value={title}
|
|
133
|
+
aria-label={i18n.t("common.title")}
|
|
134
|
+
onChange={(event) => {
|
|
135
|
+
setTitle(event.currentTarget.value);
|
|
136
|
+
edit("title");
|
|
137
|
+
}}
|
|
138
|
+
onBlur={() => {
|
|
139
|
+
if (!edited.title) return;
|
|
140
|
+
settle("title");
|
|
141
|
+
const next = title.trim();
|
|
142
|
+
// Empty is not a title the contract accepts, so the field goes back to what it was
|
|
143
|
+
// instead of sending a write that is refused.
|
|
144
|
+
if (next === "") return setTitle(task.title);
|
|
145
|
+
if (next !== task.title) board.update.mutate({ taskId: task.id, title: next });
|
|
146
|
+
}}
|
|
147
|
+
className="w-full rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
148
|
+
/>
|
|
149
|
+
</Field>
|
|
150
|
+
|
|
151
|
+
<Field label={i18n.t("board.column.status")}>
|
|
152
|
+
<div className="flex items-center gap-2">
|
|
153
|
+
<span
|
|
154
|
+
aria-hidden="true"
|
|
155
|
+
className={`size-2.5 shrink-0 rounded-full ${toneOf(task.status, board.statuses).dot}`}
|
|
156
|
+
/>
|
|
157
|
+
<select
|
|
158
|
+
value={task.status}
|
|
159
|
+
aria-label={i18n.t("board.column.status")}
|
|
160
|
+
// ⚠️ A move, not an update. Archiving is this same control with `archived` chosen —
|
|
161
|
+
// there is no separate verb on the server and there is none here either, because it
|
|
162
|
+
// is not a separate act (#285).
|
|
163
|
+
// Both neighbours null on purpose: changing the column from here puts the task at the
|
|
164
|
+
// end of the one it lands in. A position is something a drag names; a select does not
|
|
165
|
+
// have one to name.
|
|
166
|
+
onChange={(event) =>
|
|
167
|
+
board.move.mutate({
|
|
168
|
+
taskId: task.id,
|
|
169
|
+
status: event.currentTarget.value,
|
|
170
|
+
afterTaskId: null,
|
|
171
|
+
beforeTaskId: null,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
175
|
+
>
|
|
176
|
+
{board.statuses.map((status) => (
|
|
177
|
+
<option key={status.id} value={status.id}>
|
|
178
|
+
{status.label}
|
|
179
|
+
</option>
|
|
180
|
+
))}
|
|
181
|
+
</select>
|
|
182
|
+
</div>
|
|
183
|
+
</Field>
|
|
184
|
+
|
|
185
|
+
<Field label={i18n.t("board.column.assignee")}>
|
|
186
|
+
{/* ⚠️ Three choices and no name search, because Intel has no user directory: `/session`
|
|
187
|
+
answers about the signed-in person and nothing else (`useUserName`). Offering a search
|
|
188
|
+
box that can only ever find one person would be a worse lie than offering the one
|
|
189
|
+
person by name. An agent is a node, so it is picked the way every node is. */}
|
|
190
|
+
<select
|
|
191
|
+
aria-label={i18n.t("board.column.assignee")}
|
|
192
|
+
value={
|
|
193
|
+
task.assignee === null
|
|
194
|
+
? ""
|
|
195
|
+
: task.assignee.type === "agent"
|
|
196
|
+
? `agent:${task.assignee.nodeId}`
|
|
197
|
+
: `user:${task.assignee.id}`
|
|
198
|
+
}
|
|
199
|
+
onChange={(event) => {
|
|
200
|
+
const value = event.currentTarget.value;
|
|
201
|
+
board.update.mutate({
|
|
202
|
+
taskId: task.id,
|
|
203
|
+
assignee:
|
|
204
|
+
value === ""
|
|
205
|
+
? null
|
|
206
|
+
: value.startsWith("agent:")
|
|
207
|
+
? { type: "agent", nodeId: value.slice("agent:".length) }
|
|
208
|
+
: { type: "user", id: value.slice("user:".length) },
|
|
209
|
+
});
|
|
210
|
+
}}
|
|
211
|
+
className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
212
|
+
>
|
|
213
|
+
<option value="">{i18n.t("board.unassigned")}</option>
|
|
214
|
+
{me ? <option value={`user:${me.id}`}>{i18n.t("board.assignee.me")}</option> : null}
|
|
215
|
+
{/* An assignee this browser cannot offer again — somebody else's id, or an agent that
|
|
216
|
+
is gone — stays selectable so that opening the panel does not silently reassign it. */}
|
|
217
|
+
{task.assignee !== null &&
|
|
218
|
+
task.assignee.type === "user" &&
|
|
219
|
+
task.assignee.id !== me?.id ? (
|
|
220
|
+
<option value={`user:${task.assignee.id}`}>{i18n.t("board.assignee.unnamed")}</option>
|
|
221
|
+
) : null}
|
|
222
|
+
{task.assignee !== null && task.assignee.type === "agent" ? (
|
|
223
|
+
<AgentOption nodeId={task.assignee.nodeId} />
|
|
224
|
+
) : null}
|
|
225
|
+
</select>
|
|
226
|
+
</Field>
|
|
227
|
+
|
|
228
|
+
<div className="grid grid-cols-2 gap-3">
|
|
229
|
+
<Field label={i18n.t("board.column.startDate")}>
|
|
230
|
+
{/* ⚠️ On blur, not on change, and this is not a preference. A native date input fires
|
|
231
|
+
`change` on EVERY keystroke once its three segments are filled, so typing the year
|
|
232
|
+
of an existing date walks through 0002, 0020, 0202, 2026 — four writes, four
|
|
233
|
+
idempotency keys, four versions and four audit entries for one gesture, every one of
|
|
234
|
+
them a valid `z.iso.date()` the server has no reason to refuse. */}
|
|
235
|
+
<input
|
|
236
|
+
type="date"
|
|
237
|
+
aria-label={i18n.t("board.column.startDate")}
|
|
238
|
+
value={startDate}
|
|
239
|
+
onChange={(event) => {
|
|
240
|
+
setStartDate(event.currentTarget.value);
|
|
241
|
+
edit("startDate");
|
|
242
|
+
}}
|
|
243
|
+
onBlur={() => {
|
|
244
|
+
if (!edited.startDate) return;
|
|
245
|
+
settle("startDate");
|
|
246
|
+
if (startDate === (task.startDate ?? "")) return;
|
|
247
|
+
board.update.mutate({ taskId: task.id, startDate: startDate || null });
|
|
248
|
+
}}
|
|
249
|
+
className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
250
|
+
/>
|
|
251
|
+
</Field>
|
|
252
|
+
<Field label={i18n.t("board.column.dueDate")}>
|
|
253
|
+
<input
|
|
254
|
+
type="date"
|
|
255
|
+
aria-label={i18n.t("board.column.dueDate")}
|
|
256
|
+
value={dueDate}
|
|
257
|
+
onChange={(event) => {
|
|
258
|
+
setDueDate(event.currentTarget.value);
|
|
259
|
+
edit("dueDate");
|
|
260
|
+
}}
|
|
261
|
+
onBlur={() => {
|
|
262
|
+
if (!edited.dueDate) return;
|
|
263
|
+
settle("dueDate");
|
|
264
|
+
if (dueDate === (task.dueDate ?? "")) return;
|
|
265
|
+
board.update.mutate({ taskId: task.id, dueDate: dueDate || null });
|
|
266
|
+
}}
|
|
267
|
+
className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
268
|
+
/>
|
|
269
|
+
</Field>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<Field label={i18n.t("board.column.labels")}>
|
|
273
|
+
<ul className="flex flex-wrap gap-1">
|
|
274
|
+
{[...new Set(task.labels)].map((label) => (
|
|
275
|
+
<li
|
|
276
|
+
key={label}
|
|
277
|
+
className="inline-flex items-center gap-1 rounded-md border bg-muted px-1.5 py-0.5 text-xs"
|
|
278
|
+
>
|
|
279
|
+
{label}
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
aria-label={i18n.t("board.removeLabel", { label })}
|
|
283
|
+
onClick={() =>
|
|
284
|
+
board.update.mutate({
|
|
285
|
+
taskId: task.id,
|
|
286
|
+
labels: task.labels.filter((existing) => existing !== label),
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
className="rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
290
|
+
>
|
|
291
|
+
<X aria-hidden="true" className="size-3" />
|
|
292
|
+
</button>
|
|
293
|
+
</li>
|
|
294
|
+
))}
|
|
295
|
+
</ul>
|
|
296
|
+
<form
|
|
297
|
+
className="mt-2 flex gap-2"
|
|
298
|
+
onSubmit={(event) => {
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
const value = newLabel.trim();
|
|
301
|
+
// A label already on the task is not added twice. The contract refuses one now
|
|
302
|
+
// (anchrd/intel#318); refusing it here as well keeps the answer to a repeated word a
|
|
303
|
+
// no-op rather than an error message about something nobody meant to do.
|
|
304
|
+
if (value === "" || task.labels.includes(value)) return;
|
|
305
|
+
board.update.mutate({ taskId: task.id, labels: [...task.labels, value] });
|
|
306
|
+
setNewLabel("");
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
<input
|
|
310
|
+
value={newLabel}
|
|
311
|
+
onChange={(event) => setNewLabel(event.currentTarget.value)}
|
|
312
|
+
placeholder={i18n.t("board.addLabel")}
|
|
313
|
+
aria-label={i18n.t("board.addLabel")}
|
|
314
|
+
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"
|
|
315
|
+
/>
|
|
316
|
+
<SmallButton type="submit" label={i18n.t("common.create")} />
|
|
317
|
+
</form>
|
|
318
|
+
</Field>
|
|
319
|
+
|
|
320
|
+
<Field label={i18n.t("board.description")}>
|
|
321
|
+
<textarea
|
|
322
|
+
value={description}
|
|
323
|
+
aria-label={i18n.t("board.description")}
|
|
324
|
+
rows={5}
|
|
325
|
+
onChange={(event) => {
|
|
326
|
+
setDescription(event.currentTarget.value);
|
|
327
|
+
edit("description");
|
|
328
|
+
}}
|
|
329
|
+
onBlur={() => {
|
|
330
|
+
if (!edited.description) return;
|
|
331
|
+
settle("description");
|
|
332
|
+
if (description !== task.description) {
|
|
333
|
+
board.update.mutate({ taskId: task.id, description });
|
|
334
|
+
}
|
|
335
|
+
}}
|
|
336
|
+
className="w-full rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
337
|
+
/>
|
|
338
|
+
</Field>
|
|
339
|
+
|
|
340
|
+
<Field label={i18n.t("board.subtasks")}>
|
|
341
|
+
<ul className="flex flex-col gap-1">
|
|
342
|
+
{children.map((child) => (
|
|
343
|
+
<li key={child.id}>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
onClick={() => select(child.id)}
|
|
347
|
+
className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
348
|
+
>
|
|
349
|
+
<span
|
|
350
|
+
aria-hidden="true"
|
|
351
|
+
className={`size-2 shrink-0 rounded-full ${toneOf(child.status, board.statuses).dot}`}
|
|
352
|
+
/>
|
|
353
|
+
<span className="min-w-0 truncate">{child.title}</span>
|
|
354
|
+
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
|
355
|
+
{labelOf(child.status, board.statuses)}
|
|
356
|
+
</span>
|
|
357
|
+
</button>
|
|
358
|
+
</li>
|
|
359
|
+
))}
|
|
360
|
+
</ul>
|
|
361
|
+
<form
|
|
362
|
+
className="mt-2 flex gap-2"
|
|
363
|
+
onSubmit={(event) => {
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
const value = newSubtask.trim();
|
|
366
|
+
if (value === "") return;
|
|
367
|
+
// A new subtask starts in its parent's column: it is part of the same piece of work,
|
|
368
|
+
// and a board where every added subtask lands in the backlog reads as a backlog that
|
|
369
|
+
// grows whenever somebody breaks a task down.
|
|
370
|
+
// ⚠️ Cleared on SUCCESS only. `BoardMaxTaskDepth` makes a fifth level a refusal, and
|
|
371
|
+
// a field emptied before the answer arrives turns that refusal into a subtask that
|
|
372
|
+
// appeared to be created and then was not there — with the typed title gone too.
|
|
373
|
+
board.add.mutate(
|
|
374
|
+
{
|
|
375
|
+
title: value,
|
|
376
|
+
parentId: task.id,
|
|
377
|
+
status: task.status,
|
|
378
|
+
assignee: null,
|
|
379
|
+
labels: [],
|
|
380
|
+
startDate: null,
|
|
381
|
+
dueDate: null,
|
|
382
|
+
dependsOn: [],
|
|
383
|
+
description: "",
|
|
384
|
+
references: [],
|
|
385
|
+
afterTaskId: null,
|
|
386
|
+
beforeTaskId: null,
|
|
387
|
+
},
|
|
388
|
+
{ onSuccess: () => setNewSubtask("") },
|
|
389
|
+
);
|
|
390
|
+
}}
|
|
391
|
+
>
|
|
392
|
+
<input
|
|
393
|
+
value={newSubtask}
|
|
394
|
+
onChange={(event) => setNewSubtask(event.currentTarget.value)}
|
|
395
|
+
placeholder={i18n.t("board.addSubtask")}
|
|
396
|
+
aria-label={i18n.t("board.addSubtask")}
|
|
397
|
+
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"
|
|
398
|
+
/>
|
|
399
|
+
<SmallButton type="submit" label={i18n.t("common.create")} />
|
|
400
|
+
</form>
|
|
401
|
+
</Field>
|
|
402
|
+
|
|
403
|
+
<Field label={i18n.t("board.dependencies")}>
|
|
404
|
+
<ul className="flex flex-col gap-1">
|
|
405
|
+
{/* ⚠️ Once per task waited for, which is what makes `key={id}` unique BY CONSTRUCTION
|
|
406
|
+
rather than by decoration (anchrd/intel#318). Two rows under one key is a React
|
|
407
|
+
warning nobody reads plus two identical lines whose two remove buttons each take
|
|
408
|
+
both away — and waiting twice for a task is waiting for it once. The write path
|
|
409
|
+
refuses a repeat now and a stored one is folded on read, so nothing arriving through
|
|
410
|
+
Intel carries one; a view still may not be what mangles odd data. */}
|
|
411
|
+
{[...new Set(task.dependsOn)].map((id) => {
|
|
412
|
+
const other = board.byId.get(id);
|
|
413
|
+
return (
|
|
414
|
+
<li key={id} className="flex items-center gap-2 text-sm">
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={() => select(id)}
|
|
418
|
+
disabled={other === undefined}
|
|
419
|
+
className="min-w-0 flex-1 truncate rounded text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring disabled:text-muted-foreground"
|
|
420
|
+
>
|
|
421
|
+
{other?.title ?? i18n.t("board.unknownTask")}
|
|
422
|
+
</button>
|
|
423
|
+
{other && !board.settled(other) ? (
|
|
424
|
+
<span className="shrink-0 rounded border border-destructive px-1 text-xs text-destructive">
|
|
425
|
+
{i18n.t("board.open")}
|
|
426
|
+
</span>
|
|
427
|
+
) : null}
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
aria-label={i18n.t("board.removeDependency")}
|
|
431
|
+
onClick={() =>
|
|
432
|
+
board.update.mutate({
|
|
433
|
+
taskId: task.id,
|
|
434
|
+
dependsOn: task.dependsOn.filter((existing) => existing !== id),
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
className="shrink-0 rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
438
|
+
>
|
|
439
|
+
<X aria-hidden="true" className="size-3.5" />
|
|
440
|
+
</button>
|
|
441
|
+
</li>
|
|
442
|
+
);
|
|
443
|
+
})}
|
|
444
|
+
</ul>
|
|
445
|
+
{/* ⚠️ Only tasks of THIS board are offered, because only they can be one: a dependency on
|
|
446
|
+
a task in another board is refused on the write path, and across boards the link is a
|
|
447
|
+
reference to the board node instead (#285). A picker that offered them would be a
|
|
448
|
+
picker whose choices are rejected. */}
|
|
449
|
+
<select
|
|
450
|
+
value=""
|
|
451
|
+
aria-label={i18n.t("board.addDependency")}
|
|
452
|
+
onChange={(event) => {
|
|
453
|
+
const id = event.currentTarget.value;
|
|
454
|
+
if (id === "") return;
|
|
455
|
+
board.update.mutate({ taskId: task.id, dependsOn: [...task.dependsOn, id] });
|
|
456
|
+
}}
|
|
457
|
+
className="mt-2 h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
458
|
+
>
|
|
459
|
+
<option value="">{i18n.t("board.addDependency")}</option>
|
|
460
|
+
{board.tasks
|
|
461
|
+
.filter((other) => other.id !== task.id && !task.dependsOn.includes(other.id))
|
|
462
|
+
.map((other) => (
|
|
463
|
+
<option key={other.id} value={other.id}>
|
|
464
|
+
{other.title}
|
|
465
|
+
</option>
|
|
466
|
+
))}
|
|
467
|
+
</select>
|
|
468
|
+
</Field>
|
|
469
|
+
|
|
470
|
+
<Field label={i18n.t("board.references")}>
|
|
471
|
+
<ul className="flex flex-col gap-1">
|
|
472
|
+
{[...new Set(task.references)].map((id) => (
|
|
473
|
+
<li key={id} className="flex items-center gap-2 text-sm">
|
|
474
|
+
<ReferenceLink
|
|
475
|
+
nodeId={id}
|
|
476
|
+
open={() => void navigate({ to: "/nodes", search: { select: id } })}
|
|
477
|
+
/>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
aria-label={i18n.t("board.removeReference")}
|
|
481
|
+
onClick={() =>
|
|
482
|
+
board.update.mutate({
|
|
483
|
+
taskId: task.id,
|
|
484
|
+
references: task.references.filter((existing) => existing !== id),
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
className="shrink-0 rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
488
|
+
>
|
|
489
|
+
<X aria-hidden="true" className="size-3.5" />
|
|
490
|
+
</button>
|
|
491
|
+
</li>
|
|
492
|
+
))}
|
|
493
|
+
</ul>
|
|
494
|
+
{addingReference ? (
|
|
495
|
+
<div className="mt-2">
|
|
496
|
+
<EntryPicker
|
|
497
|
+
kind={null}
|
|
498
|
+
value=""
|
|
499
|
+
label={i18n.t("board.addReference")}
|
|
500
|
+
onSelect={(entryId) => {
|
|
501
|
+
setAddingReference(false);
|
|
502
|
+
if (task.references.includes(entryId)) return;
|
|
503
|
+
board.update.mutate({
|
|
504
|
+
taskId: task.id,
|
|
505
|
+
references: [...task.references, entryId],
|
|
506
|
+
});
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
) : (
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
onClick={() => setAddingReference(true)}
|
|
514
|
+
className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
515
|
+
>
|
|
516
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
517
|
+
{i18n.t("board.addReference")}
|
|
518
|
+
</button>
|
|
519
|
+
)}
|
|
520
|
+
</Field>
|
|
521
|
+
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
onClick={() => setConfirmDelete(true)}
|
|
525
|
+
className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-destructive px-3 text-sm text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring"
|
|
526
|
+
>
|
|
527
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
528
|
+
{i18n.t("board.deleteTask")}
|
|
529
|
+
</button>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
|
533
|
+
<DialogContent>
|
|
534
|
+
<DialogHeader>
|
|
535
|
+
<DialogTitle>{i18n.t("board.deleteTask")}</DialogTitle>
|
|
536
|
+
{/* ⚠️ The COUNT, always, and not only when there are descendants. Deleting cascades
|
|
537
|
+
(#285), and "delete this task?" in front of a delete that takes four is a question
|
|
538
|
+
that was answered about something else. */}
|
|
539
|
+
<DialogDescription>
|
|
540
|
+
{i18n.t("board.deleteConfirm", {
|
|
541
|
+
task: task.title,
|
|
542
|
+
count: subtreeSize(task.id, board.childrenOf),
|
|
543
|
+
})}
|
|
544
|
+
</DialogDescription>
|
|
545
|
+
</DialogHeader>
|
|
546
|
+
<DialogFooter>
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
onClick={() => setConfirmDelete(false)}
|
|
550
|
+
className="h-9 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
551
|
+
>
|
|
552
|
+
{i18n.t("common.close")}
|
|
553
|
+
</button>
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
onClick={() => {
|
|
557
|
+
board.remove.mutate({ taskId: task.id }, { onSuccess: () => close() });
|
|
558
|
+
setConfirmDelete(false);
|
|
559
|
+
}}
|
|
560
|
+
className="h-9 rounded-md bg-destructive px-3 text-sm text-destructive-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
561
|
+
>
|
|
562
|
+
{i18n.t("board.deleteTask")}
|
|
563
|
+
</button>
|
|
564
|
+
</DialogFooter>
|
|
565
|
+
</DialogContent>
|
|
566
|
+
</Dialog>
|
|
567
|
+
{/* ⚠️ The server's own count is NOT reported here, and cannot be: a successful delete closes
|
|
568
|
+
this panel in the same commit, so a line under it would never be read for the task it is
|
|
569
|
+
about — and would then greet whoever opened the next card, announcing a number about a
|
|
570
|
+
task nobody touched. It belongs to the screen, which outlives the delete (`board.tsx`). */}
|
|
571
|
+
{task.status === ArchivedBoardStatusId ? (
|
|
572
|
+
<p className="px-4 pb-4 text-xs text-muted-foreground">{i18n.t("board.archivedHint")}</p>
|
|
573
|
+
) : null}
|
|
574
|
+
</aside>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* One labelled block of the panel.
|
|
580
|
+
*
|
|
581
|
+
* ⚠️ A `<fieldset>`, deliberately NOT a `<label>`. Half of these hold a list and a form rather than
|
|
582
|
+
* one control — subtasks, dependencies, references — and a `<label>` around several controls names
|
|
583
|
+
* none of them. The legend announces the section on entry, and the control inside carries its own
|
|
584
|
+
* accessible name.
|
|
585
|
+
*/
|
|
586
|
+
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
587
|
+
return (
|
|
588
|
+
<fieldset className="flex min-w-0 flex-col gap-1.5">
|
|
589
|
+
<legend className="mb-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
590
|
+
{label}
|
|
591
|
+
</legend>
|
|
592
|
+
{children}
|
|
593
|
+
</fieldset>
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function SmallButton({ type, label }: { type: "submit" | "button"; label: string }) {
|
|
598
|
+
return (
|
|
599
|
+
<button
|
|
600
|
+
type={type}
|
|
601
|
+
className="h-8 shrink-0 rounded-md border px-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
602
|
+
>
|
|
603
|
+
{label}
|
|
604
|
+
</button>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function AgentOption({ nodeId }: { nodeId: string }) {
|
|
609
|
+
const entry = useEntryTitle(nodeId);
|
|
610
|
+
return <option value={`agent:${nodeId}`}>{entry.title}</option>;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// A reference is a node id; what a reader needs is its name, and clicking it has to lead there.
|
|
614
|
+
// ⚠️ A target the reader may not see is absent from the graph `useEntryTitle` reads, and it says
|
|
615
|
+
// so rather than naming it — the same answer a deleted one gets, on purpose (#41).
|
|
616
|
+
function ReferenceLink({ nodeId, open }: { nodeId: string; open(): void }) {
|
|
617
|
+
const entry = useEntryTitle(nodeId);
|
|
618
|
+
return (
|
|
619
|
+
<button
|
|
620
|
+
type="button"
|
|
621
|
+
onClick={open}
|
|
622
|
+
disabled={!entry.known}
|
|
623
|
+
className="inline-flex min-w-0 flex-1 items-center gap-1.5 rounded text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring disabled:text-muted-foreground disabled:hover:no-underline"
|
|
624
|
+
>
|
|
625
|
+
<ExternalLink aria-hidden="true" className="size-3.5 shrink-0" />
|
|
626
|
+
<span className="min-w-0 truncate">{entry.title}</span>
|
|
627
|
+
</button>
|
|
628
|
+
);
|
|
629
|
+
}
|