@anchrd/intel-ui 0.40.0 → 0.42.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 +8 -1
- package/src/app/app-tree/app-tree.tsx +21 -0
- package/src/attachment-viewer/attachment-viewer.tsx +305 -0
- package/src/board/board-assignee/board-assignee.tsx +45 -0
- package/src/board/board-chip/board-chip.tsx +31 -0
- package/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/src/board/board-crumbs/board-crumbs.tsx +108 -0
- package/src/board/board-kanban/board-kanban.tsx +43 -8
- package/src/board/board-open-task/board-open-task.ts +32 -0
- package/src/board/board-panel/board-panel.tsx +17 -37
- package/src/board/board-status/board-status.tsx +60 -0
- package/src/board/board-table/board-table.tsx +207 -101
- package/src/board/board-task/board-task.tsx +144 -195
- package/src/board/board-title-row/board-title-row.tsx +68 -0
- package/src/file-preview/file-preview-view.tsx +342 -0
- package/src/file-preview/file-preview.tsx +28 -0
- package/src/file-preview/pdf-file-preview.tsx +5 -0
- package/src/file-preview/presentation-file-preview.tsx +21 -0
- package/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/src/file-preview/word-file-preview.tsx +5 -0
- package/src/i18n/de.json +62 -6
- package/src/i18n/en.json +62 -6
- package/src/i18n/es.json +62 -6
- package/src/nodes/nodes.tsx +26 -52
- package/src/resource-menu/resource-menu.tsx +18 -12
- package/src/title-row/title-row.tsx +33 -6
- package/vite.config.ts +111 -3
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
-
import {
|
|
3
|
-
import { ChevronLeft, Lock, Plus, Square, SquareCheck } from "lucide-react";
|
|
2
|
+
import { Lock, Plus, Square, SquareCheck } from "lucide-react";
|
|
4
3
|
import { useState } from "react";
|
|
5
|
-
import {
|
|
4
|
+
import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
|
|
5
|
+
import { BoardChip } from "@/board/board-chip/board-chip.tsx";
|
|
6
6
|
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
7
|
+
import { StatusChip } from "@/board/board-status/board-status.tsx";
|
|
7
8
|
import {
|
|
8
9
|
DropdownMenu,
|
|
9
10
|
DropdownMenuContent,
|
|
10
11
|
DropdownMenuItem,
|
|
11
|
-
DropdownMenuRadioGroup,
|
|
12
|
-
DropdownMenuRadioItem,
|
|
13
12
|
DropdownMenuTrigger,
|
|
14
13
|
} from "@/components/ui/dropdown-menu.tsx";
|
|
15
14
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
16
|
-
import { initials } from "@/user-name/user-name.ts";
|
|
17
15
|
import { type LinkKind, linksOf, progressOf, type TaskLink } from "./board-task.ts";
|
|
18
16
|
|
|
19
17
|
const linkIcons: Record<LinkKind, typeof Lock> = {
|
|
@@ -22,33 +20,6 @@ const linkIcons: Record<LinkKind, typeof Lock> = {
|
|
|
22
20
|
blocked: Lock,
|
|
23
21
|
};
|
|
24
22
|
|
|
25
|
-
/**
|
|
26
|
-
* Who the task is for, as the circle the access summary uses.
|
|
27
|
-
*
|
|
28
|
-
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed, the field stays
|
|
29
|
-
* `assigneeId`. A row of circles here would imply a second data model that does not exist.
|
|
30
|
-
*/
|
|
31
|
-
function Assignee({ id }: { id: string | null }) {
|
|
32
|
-
const i18n = useI18n();
|
|
33
|
-
const label = useAssigneeLabel(id);
|
|
34
|
-
if (id === null) {
|
|
35
|
-
return <span className="text-xs text-muted-foreground">{label}</span>;
|
|
36
|
-
}
|
|
37
|
-
return (
|
|
38
|
-
<span
|
|
39
|
-
// ⚠️ `role="img"` and not a bare span: two initials are a picture of a name, and a plain
|
|
40
|
-
// `span` carries no `aria-label` at all — the attribute is dropped, and the circle is then
|
|
41
|
-
// read out as the two letters it happens to contain.
|
|
42
|
-
role="img"
|
|
43
|
-
aria-label={label}
|
|
44
|
-
title={label}
|
|
45
|
-
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
46
|
-
>
|
|
47
|
-
{initials(label)}
|
|
48
|
-
</span>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
23
|
/** One entry of the link list. The icon carries the kind; nothing writes the word out. */
|
|
53
24
|
function LinkRow({ link, onOpen }: { link: TaskLink; onOpen(id: string): void }) {
|
|
54
25
|
const i18n = useI18n();
|
|
@@ -83,19 +54,15 @@ export function BoardTaskDocument({
|
|
|
83
54
|
task,
|
|
84
55
|
board,
|
|
85
56
|
onOpen,
|
|
86
|
-
onClose,
|
|
87
57
|
body,
|
|
88
58
|
}: {
|
|
89
59
|
task: BoardTask;
|
|
90
60
|
board: BoardHandle;
|
|
91
61
|
onOpen(taskId: string): void;
|
|
92
|
-
onClose(): void;
|
|
93
62
|
/** The markdown surface, handed in so this component stays free of the editor's own loading. */
|
|
94
63
|
body: React.ReactNode;
|
|
95
64
|
}) {
|
|
96
65
|
const i18n = useI18n();
|
|
97
|
-
const [label, setLabel] = useState("");
|
|
98
|
-
const [adding, setAdding] = useState(false);
|
|
99
66
|
|
|
100
67
|
const links = linksOf(task, board.columns);
|
|
101
68
|
const progress = progressOf(links);
|
|
@@ -117,137 +84,79 @@ export function BoardTaskDocument({
|
|
|
117
84
|
});
|
|
118
85
|
};
|
|
119
86
|
|
|
120
|
-
// ⚠️ A single date has to say WHICH one it is. Drawn bare, a start date and a due date look
|
|
121
|
-
// identical — and reading a start date as a deadline is the expensive direction of that mistake.
|
|
122
|
-
const dates =
|
|
123
|
-
task.startDate !== null && task.dueDate !== null
|
|
124
|
-
? `${task.startDate.slice(0, 10)} – ${task.dueDate.slice(0, 10)}`
|
|
125
|
-
: task.startDate !== null
|
|
126
|
-
? i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })
|
|
127
|
-
: task.dueDate !== null
|
|
128
|
-
? i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })
|
|
129
|
-
: null;
|
|
130
|
-
|
|
131
87
|
return (
|
|
132
88
|
<div
|
|
133
89
|
className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-6"
|
|
134
90
|
aria-busy={board.isWriting}
|
|
135
91
|
>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
className="flex w-fit items-center gap-1 rounded-md text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
140
|
-
>
|
|
141
|
-
<ChevronLeft aria-hidden="true" className="size-4" />
|
|
142
|
-
{board.title}
|
|
143
|
-
</button>
|
|
144
|
-
|
|
145
|
-
<h1 className="text-2xl font-semibold tracking-tight">{task.title}</h1>
|
|
146
|
-
|
|
92
|
+
{/* ⚠️ No heading and no way back HERE. Both live in the title row above since #692: the path
|
|
93
|
+
there ends in this card's name, so a heading would print it a second time, and an arrow
|
|
94
|
+
here would be a second control meaning what the one up there already means. */}
|
|
147
95
|
{/* ⚠️ ONE line of chips, and no field labels. "Status:" in front of a chip that says "Backlog"
|
|
148
96
|
says nothing the chip does not — and it turns a quiet head into a form. */}
|
|
149
97
|
<div className="flex flex-wrap items-center gap-2">
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
</DropdownMenuTrigger>
|
|
157
|
-
<DropdownMenuContent align="start">
|
|
158
|
-
<DropdownMenuRadioGroup
|
|
159
|
-
value={task.status}
|
|
160
|
-
// ⚠️ Where a task SITS is a move, not a field edit: the server computes a position and
|
|
161
|
-
// checks the cycles for it. Everything else on this screen is a plain change.
|
|
162
|
-
onValueChange={(next) => write({ status: next })}
|
|
163
|
-
>
|
|
164
|
-
{columns.map((entry) => (
|
|
165
|
-
<DropdownMenuRadioItem key={entry.id} value={entry.id}>
|
|
166
|
-
{entry.title}
|
|
167
|
-
</DropdownMenuRadioItem>
|
|
168
|
-
))}
|
|
169
|
-
<DropdownMenuRadioItem value={ARCHIVE_COLUMN_ID}>
|
|
170
|
-
{i18n.t("board.column.archive")}
|
|
171
|
-
</DropdownMenuRadioItem>
|
|
172
|
-
</DropdownMenuRadioGroup>
|
|
173
|
-
</DropdownMenuContent>
|
|
174
|
-
</DropdownMenu>
|
|
175
|
-
|
|
176
|
-
<Assignee id={task.assigneeId} />
|
|
98
|
+
<StatusChip
|
|
99
|
+
status={task.status}
|
|
100
|
+
title={column?.title ?? task.status}
|
|
101
|
+
columns={columns}
|
|
102
|
+
write={write}
|
|
103
|
+
/>
|
|
177
104
|
|
|
178
|
-
{dates
|
|
179
|
-
|
|
105
|
+
{/* ⚠️ Two chips when both dates are set, not a range. A range reads as one fact; these
|
|
106
|
+
are two, and each is removed on its own. */}
|
|
107
|
+
{task.startDate === null ? null : (
|
|
108
|
+
<BoardChip
|
|
109
|
+
label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
|
|
110
|
+
action={i18n.t("board.clearStart")}
|
|
111
|
+
onClick={() => write({ startDate: null })}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
{task.dueDate === null ? null : (
|
|
115
|
+
<BoardChip
|
|
116
|
+
label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
|
|
117
|
+
action={i18n.t("board.clearDue")}
|
|
118
|
+
onClick={() => write({ dueDate: null })}
|
|
119
|
+
/>
|
|
180
120
|
)}
|
|
181
121
|
|
|
182
122
|
{task.labels.map((entry) => (
|
|
183
|
-
<
|
|
123
|
+
<BoardChip
|
|
184
124
|
key={entry}
|
|
185
|
-
|
|
186
|
-
|
|
125
|
+
label={entry}
|
|
126
|
+
action={i18n.t("board.removeLabel")}
|
|
187
127
|
onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
|
|
188
|
-
|
|
189
|
-
>
|
|
190
|
-
{entry}
|
|
191
|
-
</button>
|
|
128
|
+
/>
|
|
192
129
|
))}
|
|
193
130
|
|
|
194
|
-
{
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
value={label}
|
|
199
|
-
aria-label={i18n.t("board.addLabel")}
|
|
200
|
-
onChange={(event) => setLabel(event.target.value)}
|
|
201
|
-
onKeyDown={(event) => {
|
|
202
|
-
if (event.key === "Escape") {
|
|
203
|
-
setAdding(false);
|
|
204
|
-
setLabel("");
|
|
205
|
-
}
|
|
206
|
-
if (event.key !== "Enter") return;
|
|
207
|
-
event.preventDefault();
|
|
208
|
-
const next = label.trim();
|
|
209
|
-
// ⚠️ Tags are REPLACED, not merged, so the whole list travels — and a duplicate would
|
|
210
|
-
// be written as one more entry rather than refused.
|
|
211
|
-
if (next.length > 0 && !task.labels.includes(next)) {
|
|
212
|
-
write({ labels: [...task.labels, next] });
|
|
213
|
-
}
|
|
214
|
-
setAdding(false);
|
|
215
|
-
setLabel("");
|
|
216
|
-
}}
|
|
217
|
-
className="w-28 rounded-full border bg-background px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
218
|
-
/>
|
|
219
|
-
) : (
|
|
220
|
-
<button
|
|
221
|
-
type="button"
|
|
222
|
-
aria-label={i18n.t("board.addLabel")}
|
|
223
|
-
onClick={() => setAdding(true)}
|
|
224
|
-
className="rounded-full border px-2 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
225
|
-
>
|
|
226
|
-
<Plus aria-hidden="true" className="size-3" />
|
|
227
|
-
</button>
|
|
131
|
+
{/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
|
|
132
|
+
it breaks the line, and a row without one simply ends earlier. */}
|
|
133
|
+
{task.assigneeId === null ? null : (
|
|
134
|
+
<AssigneeChip id={task.assigneeId} clear={() => write({ assigneeId: null })} />
|
|
228
135
|
)}
|
|
136
|
+
|
|
137
|
+
<Adder task={task} board={board} write={write} />
|
|
229
138
|
</div>
|
|
230
139
|
|
|
231
|
-
{/* ⚠️ ONE list, not three blocks
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
140
|
+
{/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
|
|
141
|
+
something in it. An empty section with a heading and a hint tells the reader that something
|
|
142
|
+
is missing; nothing at all tells them there is nothing, which is the truth (#692). */}
|
|
143
|
+
{links.length === 0 ? null : (
|
|
144
|
+
<section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
|
|
145
|
+
<h2 className="flex items-center gap-2 text-sm font-medium">
|
|
146
|
+
{i18n.t("board.links")}
|
|
147
|
+
{progress === null ? null : (
|
|
148
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
149
|
+
{i18n.t("board.progress", { done: progress.done, total: progress.total })}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</h2>
|
|
243
153
|
<ul className="flex flex-col">
|
|
244
154
|
{links.map((link) => (
|
|
245
155
|
<LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
|
|
246
156
|
))}
|
|
247
157
|
</ul>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
</section>
|
|
158
|
+
</section>
|
|
159
|
+
)}
|
|
251
160
|
|
|
252
161
|
{/* The markdown text is the largest element of the page — everything above is the quiet part.
|
|
253
162
|
⚠️ The slot is not decoration: a test that asks whether the BODY drew something has to be
|
|
@@ -265,77 +174,122 @@ export function BoardTaskDocument({
|
|
|
265
174
|
* and "waits for". That is `#371`: the reader decides WHAT they are linking once they know which
|
|
266
175
|
* card they mean, not before.
|
|
267
176
|
*/
|
|
268
|
-
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What the card can still be given. The order matches the chips it produces.
|
|
180
|
+
*
|
|
181
|
+
* ⚠️ **No `assignee` here, and that is a gap rather than a decision.** Setting one needs a person to
|
|
182
|
+
* pick from, and this application can only ever DISPLAY principals it already sees in a grant: there
|
|
183
|
+
* is no list to choose from anywhere in the data provider. An entry that opens nothing is worse than
|
|
184
|
+
* a missing one, because it looks like the feature is there (`#700`).
|
|
185
|
+
*/
|
|
186
|
+
const addable = ["label", "start", "due"] as const;
|
|
187
|
+
const linkable = ["subtask", "blocker"] as const;
|
|
188
|
+
type Addable = (typeof addable)[number] | (typeof linkable)[number];
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* ⚠️ **ONE plus for everything the card can carry**, and the kind is chosen afterwards. Two
|
|
192
|
+
* controls, one for fields and one for links, asked the reader to know which of the two a thing was
|
|
193
|
+
* before they could add it, and that is a question about our data model, not about their work.
|
|
194
|
+
*/
|
|
195
|
+
function Adder({
|
|
196
|
+
task,
|
|
197
|
+
board,
|
|
198
|
+
write,
|
|
199
|
+
}: {
|
|
200
|
+
task: BoardTask;
|
|
201
|
+
board: BoardHandle;
|
|
202
|
+
write(input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>): void;
|
|
203
|
+
}) {
|
|
269
204
|
const i18n = useI18n();
|
|
270
|
-
const [kind, setKind] = useState<
|
|
271
|
-
const [
|
|
205
|
+
const [kind, setKind] = useState<Addable | null>(null);
|
|
206
|
+
const [draft, setDraft] = useState("");
|
|
207
|
+
const close = () => {
|
|
208
|
+
setKind(null);
|
|
209
|
+
setDraft("");
|
|
210
|
+
};
|
|
272
211
|
|
|
273
212
|
const candidates = board.columns
|
|
274
213
|
.flatMap((entry) => entry.cards)
|
|
275
214
|
.filter((card) => card.id !== task.id && card.parentTaskId !== task.id);
|
|
276
215
|
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
216
|
+
const commit = () => {
|
|
217
|
+
const value = draft.trim();
|
|
218
|
+
if (value.length === 0 || board.isWriting) return close();
|
|
219
|
+
if (kind === "label") {
|
|
220
|
+
// ⚠️ Tags are REPLACED, not merged, so the whole list travels, and a duplicate would be
|
|
221
|
+
// written as one more entry rather than refused.
|
|
222
|
+
if (!task.labels.includes(value)) write({ labels: [...task.labels, value] });
|
|
223
|
+
}
|
|
224
|
+
if (kind === "start") write({ startDate: `${value}T00:00:00.000Z` });
|
|
225
|
+
if (kind === "due") write({ dueDate: `${value}T00:00:00.000Z` });
|
|
226
|
+
if (kind === "subtask") {
|
|
227
|
+
void board
|
|
228
|
+
.createTask({
|
|
229
|
+
title: value,
|
|
230
|
+
// A subtask starts in the same column as the task it belongs to: the first column would
|
|
231
|
+
// claim work was reset that never started.
|
|
232
|
+
status: task.status,
|
|
233
|
+
parentTaskId: task.id,
|
|
234
|
+
assigneeId: null,
|
|
235
|
+
labels: [],
|
|
236
|
+
startDate: null,
|
|
237
|
+
dueDate: null,
|
|
238
|
+
dependsOn: null,
|
|
239
|
+
idempotencyKey: crypto.randomUUID(),
|
|
240
|
+
})
|
|
241
|
+
.then(close);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
close();
|
|
280
245
|
};
|
|
281
246
|
|
|
282
247
|
return (
|
|
283
|
-
|
|
248
|
+
<>
|
|
284
249
|
<DropdownMenu>
|
|
285
|
-
<DropdownMenuTrigger
|
|
250
|
+
<DropdownMenuTrigger
|
|
251
|
+
aria-label={i18n.t("board.add")}
|
|
252
|
+
className="rounded-full border px-2 py-0.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
253
|
+
>
|
|
286
254
|
<Plus aria-hidden="true" className="size-3" />
|
|
287
|
-
{i18n.t("board.addLink")}
|
|
288
255
|
</DropdownMenuTrigger>
|
|
289
256
|
<DropdownMenuContent align="start">
|
|
290
|
-
|
|
291
|
-
{
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
257
|
+
{addable.map((entry) => (
|
|
258
|
+
<DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
|
|
259
|
+
{i18n.t(`board.add.${entry}`)}
|
|
260
|
+
</DropdownMenuItem>
|
|
261
|
+
))}
|
|
262
|
+
{linkable.map((entry) => (
|
|
263
|
+
<DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
|
|
264
|
+
{i18n.t(`board.link.${entry}`)}
|
|
265
|
+
</DropdownMenuItem>
|
|
266
|
+
))}
|
|
296
267
|
</DropdownMenuContent>
|
|
297
268
|
</DropdownMenu>
|
|
298
269
|
|
|
299
|
-
{
|
|
300
|
-
subtask is CREATED — where a task sits is the node tree, and moving an existing card under
|
|
301
|
-
another needs `node_update` with the version it was last read at, which this answer does
|
|
302
|
-
not carry. A blocker is a field on this card and only needs the card that is meant. */}
|
|
303
|
-
{kind === "subtask" ? (
|
|
270
|
+
{kind === null || kind === "blocker" ? null : (
|
|
304
271
|
<input
|
|
305
272
|
// biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
|
|
306
273
|
autoFocus
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
274
|
+
type={kind === "start" || kind === "due" ? "date" : "text"}
|
|
275
|
+
value={draft}
|
|
276
|
+
aria-label={i18n.t(kind === "subtask" ? "board.link.subtask" : `board.add.${kind}`)}
|
|
277
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
310
278
|
onKeyDown={(event) => {
|
|
311
279
|
if (event.key === "Escape") close();
|
|
312
280
|
if (event.key !== "Enter") return;
|
|
313
281
|
event.preventDefault();
|
|
314
|
-
|
|
315
|
-
if (next.length === 0 || board.isWriting) return;
|
|
316
|
-
void board
|
|
317
|
-
.createTask({
|
|
318
|
-
title: next,
|
|
319
|
-
// A subtask starts in the same column as the task it belongs to — the alternative
|
|
320
|
-
// is the first column, which claims work was reset that never started.
|
|
321
|
-
status: task.status,
|
|
322
|
-
parentTaskId: task.id,
|
|
323
|
-
assigneeId: null,
|
|
324
|
-
labels: [],
|
|
325
|
-
startDate: null,
|
|
326
|
-
dueDate: null,
|
|
327
|
-
dependsOn: null,
|
|
328
|
-
idempotencyKey: crypto.randomUUID(),
|
|
329
|
-
})
|
|
330
|
-
.then(close);
|
|
282
|
+
commit();
|
|
331
283
|
}}
|
|
332
|
-
className="w-
|
|
284
|
+
className="w-36 rounded-full border bg-background px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
333
285
|
/>
|
|
334
|
-
)
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{kind !== "blocker" ? null : (
|
|
335
289
|
<DropdownMenu open onOpenChange={(next) => !next && close()}>
|
|
336
290
|
<DropdownMenuTrigger
|
|
337
291
|
aria-label={i18n.t("board.link.blocker")}
|
|
338
|
-
className="rounded-
|
|
292
|
+
className="rounded-full border px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
339
293
|
>
|
|
340
294
|
{i18n.t("board.link.blocker")}
|
|
341
295
|
</DropdownMenuTrigger>
|
|
@@ -345,12 +299,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
|
|
|
345
299
|
key={card.id}
|
|
346
300
|
onSelect={() => {
|
|
347
301
|
close();
|
|
348
|
-
|
|
349
|
-
void board.updateTask({
|
|
350
|
-
taskId: task.id,
|
|
351
|
-
dependsOn: card.id,
|
|
352
|
-
idempotencyKey: crypto.randomUUID(),
|
|
353
|
-
});
|
|
302
|
+
write({ dependsOn: card.id });
|
|
354
303
|
}}
|
|
355
304
|
>
|
|
356
305
|
{card.title}
|
|
@@ -358,7 +307,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
|
|
|
358
307
|
))}
|
|
359
308
|
</DropdownMenuContent>
|
|
360
309
|
</DropdownMenu>
|
|
361
|
-
)
|
|
362
|
-
|
|
310
|
+
)}
|
|
311
|
+
</>
|
|
363
312
|
);
|
|
364
313
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Node } from "@anchrd/intel-contract/node";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { crumbsOf } from "@/board/board-crumbs/board-crumbs.ts";
|
|
4
|
+
import { BoardCrumbs } from "@/board/board-crumbs/board-crumbs.tsx";
|
|
5
|
+
import { useBoard } from "@/board/board-data/board-data.ts";
|
|
6
|
+
import { useOpenTask } from "@/board/board-open-task/board-open-task.ts";
|
|
7
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
8
|
+
import { TitleRow } from "@/title-row/title-row.tsx";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one line above a board, and above a card opened on it.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **The SAME `TitleRow`, with the path in place of the name.** A second header component for the
|
|
14
|
+
* open card would look identical today and drift tomorrow: the order of the right-hand group, the
|
|
15
|
+
* shadow on scroll and the slots are decided in exactly one place, and this keeps it that way (#53).
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **The menu then belongs to the CARD, not to the board.** Rename, move and archive act on what
|
|
18
|
+
* the reader is looking at. That is also where renaming a card happens, since the path carries its
|
|
19
|
+
* name and there is no heading to edit (#692).
|
|
20
|
+
*/
|
|
21
|
+
export function BoardTitleRow({ node }: { node: Node }) {
|
|
22
|
+
const { openId, open } = useOpenTask();
|
|
23
|
+
const board = useBoard(node.id);
|
|
24
|
+
const { data } = useIntelRouterContext();
|
|
25
|
+
|
|
26
|
+
// ⚠️ The same key the open card's body already uses, so this costs no second request.
|
|
27
|
+
const task = useQuery({
|
|
28
|
+
queryKey: ["node", openId],
|
|
29
|
+
queryFn: () => data.getNode(openId ?? ""),
|
|
30
|
+
enabled: openId !== null,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const tasks = board.columns.flatMap((entry) => entry.cards);
|
|
34
|
+
const openTask = tasks.find((card) => card.id === openId);
|
|
35
|
+
|
|
36
|
+
// ⚠️ Until the card is in the ANSWER, the board's own row stands. Drawing a path around a name
|
|
37
|
+
// nobody knows yet would flicker a wrong one into place for a moment.
|
|
38
|
+
//
|
|
39
|
+
// ⚠️ The card's NODE is a different wait, and it must not gate the path: it is only needed for the
|
|
40
|
+
// menu, and holding the whole row for it would show the board's name first and swap it a moment
|
|
41
|
+
// later — two different headers in sequence for one navigation.
|
|
42
|
+
if (openId === null || openTask === undefined) {
|
|
43
|
+
return (
|
|
44
|
+
<TitleRow title={node.title} description={node.description} target={{ type: "node", node }} />
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const crumbs = <BoardCrumbs crumbs={crumbsOf(openId, board.title, tasks)} onOpen={open} />;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ⚠️ **No menu at all while the card's node is missing**, rather than the board's. Rename, move
|
|
52
|
+
* and archive would otherwise act on the BOARD, and `archive` fires without asking: a control
|
|
53
|
+
* that is not there yet costs a moment, one that acts on the wrong thing costs the thing.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ **Missing covers two states, and deliberately treats them the same**: the read is still on
|
|
56
|
+
* its way, or it was refused. Neither gives this row a card to act on, and telling them apart
|
|
57
|
+
* here would only decide WHICH wrong menu to draw.
|
|
58
|
+
*/
|
|
59
|
+
if (task.data === undefined) return <TitleRow title={openTask.title} crumbs={crumbs} />;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<TitleRow
|
|
63
|
+
title={openTask.title}
|
|
64
|
+
crumbs={crumbs}
|
|
65
|
+
target={{ type: "node", node: task.data.node }}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|