@anchrd/intel-ui 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/access-summary/access-summary.tsx +1 -11
- package/src/app/app-tree/app-tree.tsx +37 -0
- package/src/board/board-assignee/board-assignee.ts +18 -0
- package/src/board/board-data/board-data.ts +31 -5
- package/src/board/board-data/board-data.types.ts +10 -1
- package/src/board/board-kanban/board-kanban.ts +63 -9
- package/src/board/board-kanban/board-kanban.tsx +371 -63
- package/src/board/board-panel/board-panel.tsx +222 -31
- package/src/board/board-settings/board-settings.tsx +209 -0
- package/src/board/board-stripes/board-stripes.ts +128 -0
- package/src/board/board-table/board-table.ts +23 -105
- package/src/board/board-table/board-table.tsx +436 -135
- package/src/board/board-task/board-task.ts +105 -0
- package/src/board/board-task/board-task.tsx +364 -0
- package/src/frontmatter/frontmatter.tsx +11 -2
- package/src/i18n/de.json +44 -3
- package/src/i18n/en.json +44 -3
- package/src/i18n/es.json +44 -3
- package/src/router/selection-search.ts +17 -2
- package/src/styles.css +29 -25
- package/src/user-name/user-name.ts +17 -0
|
@@ -1,74 +1,265 @@
|
|
|
1
1
|
import type { BoardTaskFilter } from "@anchrd/intel-contract/board";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
|
-
import {
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useNavigate, useSearch } from "@tanstack/react-router";
|
|
5
|
+
import type { ColumnVisibilityState } from "@tanstack/react-table";
|
|
6
|
+
import { KanbanSquare, Settings, Table2 } from "lucide-react";
|
|
7
|
+
import { Suspense, useCallback, useState } from "react";
|
|
8
|
+
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
4
9
|
import { useBoard } from "@/board/board-data/board-data.ts";
|
|
5
10
|
import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
|
|
6
|
-
import
|
|
11
|
+
import { BoardSettings } from "@/board/board-settings/board-settings.tsx";
|
|
7
12
|
import { BoardTable } from "@/board/board-table/board-table.tsx";
|
|
13
|
+
import { BoardTaskDocument } from "@/board/board-task/board-task.tsx";
|
|
8
14
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
15
|
+
import { NodeEditor } from "@/node-editor/node-editor.tsx";
|
|
16
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
17
|
+
import { openTaskFrom } from "@/router/selection-search.ts";
|
|
9
18
|
|
|
10
19
|
type View = "kanban" | "table";
|
|
11
20
|
|
|
21
|
+
const views = [
|
|
22
|
+
{ name: "kanban", Icon: KanbanSquare },
|
|
23
|
+
{ name: "table", Icon: Table2 },
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
12
26
|
/**
|
|
13
27
|
* A board on the node screen, beside the folder, the table and the editor (D66, #376).
|
|
14
28
|
*
|
|
15
29
|
* ⚠️ The whole board is ONE answer. `board_get` returns the columns and every card in them, and the
|
|
16
|
-
* views below read that one object — no view fetches per card, per column, or on scroll.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
30
|
+
* views below read that one object — no view fetches per card, per column, or on scroll.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ **The controls live in the title line, not in a row of their own** (#363). `title-actions` puts
|
|
33
|
+
* them before the three-dot menu, which is where everything that acts on THIS thing already sits —
|
|
34
|
+
* a third bar above the board would be a permanent row for something nobody uses twice a minute.
|
|
19
35
|
*/
|
|
20
36
|
export function BoardPanel({ node }: { node: Node }) {
|
|
21
37
|
const i18n = useI18n();
|
|
22
38
|
const [view, setView] = useState<View>("kanban");
|
|
39
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
40
|
+
// Which columns the table shows outlives the trip through the kanban — see `BoardTable`.
|
|
41
|
+
const [hidden, setHidden] = useState<ColumnVisibilityState>({});
|
|
42
|
+
/**
|
|
43
|
+
* ⚠️ **The filter lives HERE and not in the table, since #671.** Opening a card unmounts the
|
|
44
|
+
* table, so a filter kept down there was silently thrown away by the one control that is the only
|
|
45
|
+
* way back out. It does NOT reach the kanban: that view reads this component's own unfiltered
|
|
46
|
+
* board answer, a different query — which is what kept the filter out of it before, and still is.
|
|
47
|
+
*/
|
|
48
|
+
const [filter, setFilter] = useState<Partial<BoardTaskFilter>>({});
|
|
49
|
+
const board = useBoard(node.id);
|
|
50
|
+
const search = useSearch({ strict: false });
|
|
51
|
+
const navigate = useNavigate();
|
|
52
|
+
const openId = openTaskFrom(search);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ⚠️ **The address, not a piece of state.** A task opened into component state would have no link
|
|
56
|
+
* and no back button — the browser's back would leave the board entirely, which is the one thing
|
|
57
|
+
* somebody pressing it after opening a card does not want.
|
|
58
|
+
*/
|
|
59
|
+
const open = useCallback(
|
|
60
|
+
(taskId: string | null) =>
|
|
61
|
+
void navigate({
|
|
62
|
+
to: ".",
|
|
63
|
+
// ⚠️ **Closing REPLACES, opening pushes.** Opening a card is a step somebody took and the
|
|
64
|
+
// back button must undo it; closing is the undo itself. Pushed as well, the history holds
|
|
65
|
+
// board → card → board, and one press of Back re-opens the card that was just closed —
|
|
66
|
+
// exactly the behaviour the address was chosen to avoid.
|
|
67
|
+
replace: taskId === null,
|
|
68
|
+
search: (previous: Record<string, unknown>) =>
|
|
69
|
+
taskId === null ? { ...previous, task: undefined } : { ...previous, task: taskId },
|
|
70
|
+
}),
|
|
71
|
+
// ⚠️ Stable, or the table's `useMemo` over its columns rebuilds on every render and the memo
|
|
72
|
+
// is decoration.
|
|
73
|
+
[navigate],
|
|
74
|
+
);
|
|
75
|
+
|
|
23
76
|
return (
|
|
24
77
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{(
|
|
78
|
+
<ActionSlot name="title-actions">
|
|
79
|
+
{/* ⚠️ **Gone while a card is open.** The switch chooses between two views OF THE BOARD, and
|
|
80
|
+
an open card is neither — left standing it reports `aria-pressed` for a view nobody is
|
|
81
|
+
looking at and does nothing when pressed, which is a control that lies twice. */}
|
|
82
|
+
{(openId === null ? views : []).map(({ name, Icon }) => (
|
|
30
83
|
<button
|
|
31
84
|
key={name}
|
|
32
85
|
type="button"
|
|
33
86
|
aria-pressed={view === name}
|
|
87
|
+
aria-label={i18n.t(`board.view.${name}`)}
|
|
34
88
|
onClick={() => setView(name)}
|
|
35
|
-
className="
|
|
89
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-accent"
|
|
36
90
|
>
|
|
37
|
-
|
|
91
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
38
92
|
</button>
|
|
39
93
|
))}
|
|
40
|
-
|
|
41
|
-
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
aria-label={i18n.t("board.settings.open")}
|
|
97
|
+
onClick={() => setSettingsOpen(true)}
|
|
98
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
99
|
+
>
|
|
100
|
+
<Settings aria-hidden="true" className="size-4" />
|
|
101
|
+
</button>
|
|
102
|
+
</ActionSlot>
|
|
103
|
+
{/* ⚠️ Mounted only while open, so its draft is seeded once per opening. Handed an `open` flag
|
|
104
|
+
instead, a draft re-seeded from the board would lose what somebody typed the moment a
|
|
105
|
+
write from elsewhere answers. */}
|
|
106
|
+
{settingsOpen ? <BoardSettings board={board} onClose={() => setSettingsOpen(false)} /> : null}
|
|
107
|
+
{openId === null ? (
|
|
108
|
+
view === "kanban" ? (
|
|
109
|
+
<KanbanView board={board} onOpen={open} />
|
|
110
|
+
) : (
|
|
111
|
+
<TableView
|
|
112
|
+
nodeId={node.id}
|
|
113
|
+
hidden={hidden}
|
|
114
|
+
onHidden={setHidden}
|
|
115
|
+
onOpen={open}
|
|
116
|
+
filter={filter}
|
|
117
|
+
onFilter={setFilter}
|
|
118
|
+
/>
|
|
119
|
+
)
|
|
120
|
+
) : (
|
|
121
|
+
// ⚠️ Drawn from the panel's own UNFILTERED board answer, which is also what the kanban
|
|
122
|
+
// reads — the open task is not a second read of the card. ⚠️ It is NOT the table's answer:
|
|
123
|
+
// the table keeps its own filtered query, so a card filtered out of the table still opens
|
|
124
|
+
// from a link. What this cannot find is a card that is gone, or one on another board.
|
|
125
|
+
<OpenTask board={board} taskId={openId} onOpen={open} />
|
|
126
|
+
)}
|
|
42
127
|
</div>
|
|
43
128
|
);
|
|
44
129
|
}
|
|
45
130
|
|
|
46
|
-
function KanbanView({
|
|
47
|
-
|
|
48
|
-
|
|
131
|
+
function KanbanView({
|
|
132
|
+
board,
|
|
133
|
+
onOpen,
|
|
134
|
+
}: {
|
|
135
|
+
board: ReturnType<typeof useBoard>;
|
|
136
|
+
onOpen(taskId: string): void;
|
|
137
|
+
}) {
|
|
138
|
+
return <BoardKanban board={board} onOpen={onOpen} />;
|
|
49
139
|
}
|
|
50
140
|
|
|
51
141
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* the
|
|
56
|
-
*
|
|
142
|
+
* One card, opened.
|
|
143
|
+
*
|
|
144
|
+
* ⚠️ **The body is the node's own document and is the ONE thing this screen reads separately.** The
|
|
145
|
+
* head and the link list come out of `board_get`; the markdown text does not live in that answer at
|
|
146
|
+
* all — a card's text is a node version, and `board_get` returns cards, not documents.
|
|
57
147
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
148
|
+
function OpenTask({
|
|
149
|
+
board,
|
|
150
|
+
taskId,
|
|
151
|
+
onOpen,
|
|
152
|
+
}: {
|
|
153
|
+
board: ReturnType<typeof useBoard>;
|
|
154
|
+
taskId: string;
|
|
155
|
+
onOpen(taskId: string | null): void;
|
|
156
|
+
}) {
|
|
157
|
+
const i18n = useI18n();
|
|
158
|
+
const task = board.columns.flatMap((entry) => entry.cards).find((card) => card.id === taskId);
|
|
159
|
+
|
|
160
|
+
if (board.isPending) {
|
|
161
|
+
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
162
|
+
}
|
|
163
|
+
// ⚠️ Back to the board rather than an empty screen. The board answer here is unfiltered, so a
|
|
164
|
+
// card missing from it is a card that is gone, archived out of view, or on a different board —
|
|
165
|
+
// a stale link and a deleted card look exactly the same from here, and both want the same way out.
|
|
166
|
+
if (task === undefined) {
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex flex-col items-start gap-3 p-6">
|
|
169
|
+
<p className="text-sm text-muted-foreground">{i18n.t("board.taskGone")}</p>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => onOpen(null)}
|
|
173
|
+
className="rounded-md border px-3 py-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
174
|
+
>
|
|
175
|
+
{board.title}
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<BoardTaskDocument
|
|
183
|
+
task={task}
|
|
184
|
+
board={board}
|
|
185
|
+
onOpen={onOpen}
|
|
186
|
+
onClose={() => onOpen(null)}
|
|
187
|
+
body={<TaskBody nodeId={task.id} />}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* ⚠️ **The filter is handed DOWN since #671, and that is safe for a different reason than it looks.**
|
|
194
|
+
* It used to live here so that unmounting the table took it along — until opening a card, which
|
|
195
|
+
* also unmounts the table, threw the filter away through the one control that is the way back out.
|
|
196
|
+
* It sits in the panel now, and the kanban still shows everything: that view reads the panel's own
|
|
197
|
+
* unfiltered query, a different key, so no filtered answer ever reaches it. Measured, not argued —
|
|
198
|
+
* after a switch there is no new request at all, and `leaves the kanban unfiltered after a filter
|
|
199
|
+
* was set in the table` renders the whole panel, so it exercises exactly this arrangement.
|
|
200
|
+
*/
|
|
201
|
+
function TableView({
|
|
202
|
+
nodeId,
|
|
203
|
+
hidden,
|
|
204
|
+
onHidden,
|
|
205
|
+
onOpen,
|
|
206
|
+
filter,
|
|
207
|
+
onFilter,
|
|
208
|
+
}: {
|
|
209
|
+
nodeId: string;
|
|
210
|
+
hidden: ColumnVisibilityState;
|
|
211
|
+
onHidden(next: ColumnVisibilityState): void;
|
|
212
|
+
onOpen(taskId: string): void;
|
|
213
|
+
filter: Partial<BoardTaskFilter>;
|
|
214
|
+
onFilter(next: Partial<BoardTaskFilter>): void;
|
|
215
|
+
}) {
|
|
62
216
|
const board = useBoard(nodeId, filter);
|
|
217
|
+
// ⚠️ Sorting and grouping live INSIDE the table since #670 — they are the table's own state and
|
|
218
|
+
// never travel. The column choice and the filter are handed DOWN from the panel because both have
|
|
219
|
+
// to outlive this component; the kanban stays unfiltered because it reads a different query.
|
|
63
220
|
return (
|
|
64
221
|
<BoardTable
|
|
65
222
|
board={board}
|
|
66
223
|
filter={filter}
|
|
67
|
-
onFilter={
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
onGroup={setGroup}
|
|
224
|
+
onFilter={onFilter}
|
|
225
|
+
hidden={hidden}
|
|
226
|
+
onHidden={onHidden}
|
|
227
|
+
onOpen={onOpen}
|
|
72
228
|
/>
|
|
73
229
|
);
|
|
74
230
|
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The card's markdown text — the node's own document, in the editor every other document uses.
|
|
234
|
+
*
|
|
235
|
+
* ⚠️ **A second read, and the only one on this screen.** `board_get` answers with cards, not with
|
|
236
|
+
* documents: a card's text is a node version and is not in that answer at all. Building a second
|
|
237
|
+
* editor here instead would be the more expensive mistake — one text surface that saves, links and
|
|
238
|
+
* renders differently from the one next door.
|
|
239
|
+
*/
|
|
240
|
+
function TaskBody({ nodeId }: { nodeId: string }) {
|
|
241
|
+
const i18n = useI18n();
|
|
242
|
+
const { data } = useIntelRouterContext();
|
|
243
|
+
const queryClient = useQueryClient();
|
|
244
|
+
const document = useQuery({
|
|
245
|
+
queryKey: ["node", nodeId],
|
|
246
|
+
queryFn: () => data.getNode(nodeId),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (document.data === undefined) {
|
|
250
|
+
return <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<Suspense
|
|
254
|
+
fallback={<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>}
|
|
255
|
+
>
|
|
256
|
+
<NodeEditor
|
|
257
|
+
key={nodeId}
|
|
258
|
+
data={data}
|
|
259
|
+
document={document.data}
|
|
260
|
+
i18n={i18n}
|
|
261
|
+
onSaved={(saved) => queryClient.setQueryData(["node", nodeId], saved)}
|
|
262
|
+
/>
|
|
263
|
+
</Suspense>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { ARCHIVE_COLUMN_ID, type BoardColumn } from "@anchrd/intel-contract/board";
|
|
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
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A board's basic settings: its columns, and whether the archive is drawn.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **The whole list is written at once and never patched.** Adding, renaming and reordering are
|
|
19
|
+
* one call, because the ORDER is the list's order — there is nothing to patch a position with. So
|
|
20
|
+
* the dialog holds a draft and sends it on save; a control that wrote per keystroke would write a
|
|
21
|
+
* version per letter.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ **The archive column is not in this list and cannot be.** It is derived from `archived_at`
|
|
24
|
+
* (D68) and the server refuses a configured column that claims its id. What the switch below
|
|
25
|
+
* changes is only whether it is drawn.
|
|
26
|
+
*/
|
|
27
|
+
export function BoardSettings({ board, onClose }: { board: BoardHandle; onClose(): void }) {
|
|
28
|
+
const i18n = useI18n();
|
|
29
|
+
// ⚠️ Seeded once per OPENING, which is why the caller mounts this only while it is open rather
|
|
30
|
+
// than handing it an `open` flag: a draft re-seeded from a prop would lose what somebody typed
|
|
31
|
+
// the moment the board answers a write from somewhere else.
|
|
32
|
+
const [columns, setColumns] = useState<BoardColumn[]>(() =>
|
|
33
|
+
board.columns
|
|
34
|
+
.filter((entry) => entry.unknown !== true && entry.column.id !== ARCHIVE_COLUMN_ID)
|
|
35
|
+
.map((entry) => entry.column),
|
|
36
|
+
);
|
|
37
|
+
const [archiveVisible, setArchiveVisible] = useState(board.archiveVisible);
|
|
38
|
+
const [failed, setFailed] = useState(false);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How many cards each column carries, taken once from the board.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ **A column with cards is not removable here** — `destructive.md`: *verweigern schlägt
|
|
44
|
+
* kaskadieren*. Removed, its cards do not vanish from the database, but their `status` names a
|
|
45
|
+
* column nobody configured any more: they fall into the stray column, and nobody was asked where
|
|
46
|
+
* they should go. That is the same cascade one level delayed.
|
|
47
|
+
*/
|
|
48
|
+
const cardsIn = new Map(board.columns.map((entry) => [entry.column.id, entry.cards.length]));
|
|
49
|
+
|
|
50
|
+
const move = (index: number, by: -1 | 1) => {
|
|
51
|
+
const next = [...columns];
|
|
52
|
+
const target = index + by;
|
|
53
|
+
const a = next[index];
|
|
54
|
+
const b = next[target];
|
|
55
|
+
if (a === undefined || b === undefined) return;
|
|
56
|
+
next[index] = b;
|
|
57
|
+
next[target] = a;
|
|
58
|
+
setColumns(next);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const save = () => {
|
|
62
|
+
setFailed(false);
|
|
63
|
+
void board
|
|
64
|
+
.setColumns({
|
|
65
|
+
columns: columns.map((column) => ({ ...column, title: column.title.trim() })),
|
|
66
|
+
archiveVisible,
|
|
67
|
+
idempotencyKey: crypto.randomUUID(),
|
|
68
|
+
})
|
|
69
|
+
.then(onClose)
|
|
70
|
+
.catch(() => setFailed(true));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ⚠️ An empty title would be refused by the contract, and a list with none at all by the server.
|
|
74
|
+
// Saying so before the call is the difference between a form and a guessing game.
|
|
75
|
+
const titles = columns.map((column) => column.title.trim());
|
|
76
|
+
/**
|
|
77
|
+
* ⚠️ **Two columns with the same NAME are refused too**, although the server only refuses
|
|
78
|
+
* duplicate ids. An id nobody sees may repeat without confusing anybody; a name is what a person
|
|
79
|
+
* reads, and two columns called the same thing are two columns nobody can tell apart — on the
|
|
80
|
+
* board, in the filter, and in this very dialog.
|
|
81
|
+
*/
|
|
82
|
+
const duplicate = titles.find((title, index) => titles.indexOf(title) !== index);
|
|
83
|
+
const incomplete =
|
|
84
|
+
columns.length === 0 || titles.some((title) => title.length === 0) || duplicate !== undefined;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
|
|
88
|
+
<DialogContent>
|
|
89
|
+
<DialogHeader>
|
|
90
|
+
<DialogTitle>{i18n.t("board.settings.title")}</DialogTitle>
|
|
91
|
+
<DialogDescription>{i18n.t("board.settings.description")}</DialogDescription>
|
|
92
|
+
</DialogHeader>
|
|
93
|
+
|
|
94
|
+
<ul className="space-y-2">
|
|
95
|
+
{columns.map((column, index) => (
|
|
96
|
+
<li key={column.id} className="flex items-center gap-2">
|
|
97
|
+
<input
|
|
98
|
+
value={column.title}
|
|
99
|
+
aria-label={i18n.t("board.settings.columnName", { column: column.title })}
|
|
100
|
+
onChange={(event) => {
|
|
101
|
+
const next = [...columns];
|
|
102
|
+
next[index] = { ...column, title: event.target.value };
|
|
103
|
+
setColumns(next);
|
|
104
|
+
}}
|
|
105
|
+
className="min-w-0 flex-1 rounded-md border bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
106
|
+
/>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
aria-label={i18n.t("board.settings.moveUp", { column: column.title })}
|
|
110
|
+
disabled={index === 0}
|
|
111
|
+
onClick={() => move(index, -1)}
|
|
112
|
+
className="rounded-md p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
|
|
113
|
+
>
|
|
114
|
+
<ChevronUp aria-hidden="true" className="size-4" />
|
|
115
|
+
</button>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
aria-label={i18n.t("board.settings.moveDown", { column: column.title })}
|
|
119
|
+
disabled={index === columns.length - 1}
|
|
120
|
+
onClick={() => move(index, 1)}
|
|
121
|
+
className="rounded-md p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
|
|
122
|
+
>
|
|
123
|
+
<ChevronDown aria-hidden="true" className="size-4" />
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
aria-label={
|
|
128
|
+
(cardsIn.get(column.id) ?? 0) > 0
|
|
129
|
+
? // ⚠️ No count in this sentence. `packages/ui/CLAUDE.md`: a sentence that
|
|
130
|
+
// carries a number has to read correctly at ONE, and "1 card(s)" is a written
|
|
131
|
+
// admission that it does not. The number is on the board anyway; here what
|
|
132
|
+
// matters is that the column is not empty.
|
|
133
|
+
i18n.t("board.settings.removeBlocked", { column: column.title })
|
|
134
|
+
: i18n.t("board.settings.remove", { column: column.title })
|
|
135
|
+
}
|
|
136
|
+
disabled={(cardsIn.get(column.id) ?? 0) > 0}
|
|
137
|
+
onClick={() => setColumns(columns.filter((entry) => entry.id !== column.id))}
|
|
138
|
+
className="rounded-md p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
|
|
139
|
+
>
|
|
140
|
+
<X aria-hidden="true" className="size-4" />
|
|
141
|
+
</button>
|
|
142
|
+
</li>
|
|
143
|
+
))}
|
|
144
|
+
</ul>
|
|
145
|
+
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() =>
|
|
149
|
+
setColumns([
|
|
150
|
+
...columns,
|
|
151
|
+
{
|
|
152
|
+
id: crypto.randomUUID(),
|
|
153
|
+
title: i18n.t("board.settings.newColumn"),
|
|
154
|
+
terminal: false,
|
|
155
|
+
},
|
|
156
|
+
])
|
|
157
|
+
}
|
|
158
|
+
className="self-start rounded-md px-2 py-1 text-sm text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
159
|
+
>
|
|
160
|
+
{i18n.t("board.settings.addColumn")}
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
<label className="flex items-start gap-3 border-t pt-3 text-sm">
|
|
164
|
+
<input
|
|
165
|
+
type="checkbox"
|
|
166
|
+
checked={archiveVisible}
|
|
167
|
+
onChange={(event) => setArchiveVisible(event.target.checked)}
|
|
168
|
+
className="mt-1"
|
|
169
|
+
/>
|
|
170
|
+
<span>
|
|
171
|
+
{i18n.t("board.settings.showArchive")}
|
|
172
|
+
<span className="block text-xs text-muted-foreground">
|
|
173
|
+
{i18n.t("board.settings.showArchiveHint")}
|
|
174
|
+
</span>
|
|
175
|
+
</span>
|
|
176
|
+
</label>
|
|
177
|
+
|
|
178
|
+
{duplicate === undefined ? null : (
|
|
179
|
+
<p role="alert" className="text-sm text-destructive">
|
|
180
|
+
{i18n.t("board.settings.duplicateName", { column: duplicate })}
|
|
181
|
+
</p>
|
|
182
|
+
)}
|
|
183
|
+
{failed ? (
|
|
184
|
+
<p role="alert" className="text-sm text-destructive">
|
|
185
|
+
{i18n.t("board.settings.failed")}
|
|
186
|
+
</p>
|
|
187
|
+
) : null}
|
|
188
|
+
|
|
189
|
+
<DialogFooter>
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={onClose}
|
|
193
|
+
className="rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
194
|
+
>
|
|
195
|
+
{i18n.t("common.cancel")}
|
|
196
|
+
</button>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
disabled={incomplete || board.isWriting}
|
|
200
|
+
onClick={save}
|
|
201
|
+
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
202
|
+
>
|
|
203
|
+
{i18n.t("common.save")}
|
|
204
|
+
</button>
|
|
205
|
+
</DialogFooter>
|
|
206
|
+
</DialogContent>
|
|
207
|
+
</Dialog>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
+
|
|
3
|
+
/** How many steps the family ramp has before it repeats (`styles.css`). */
|
|
4
|
+
export const FAMILY_TONES = 6;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ⚠️ **A guard against a cycle in the parent chain, not a limit on nesting.** The depth itself is
|
|
8
|
+
* unbounded (D37) and the server refuses a move into a node's own descendant — but this walk runs
|
|
9
|
+
* on whatever answer arrived, and an answer that somehow carried a loop would hang the render
|
|
10
|
+
* rather than draw a wrong card. A number this large is never reached by real work.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_WALK = 1_000;
|
|
13
|
+
|
|
14
|
+
export interface Family {
|
|
15
|
+
/** 1 for a root task, 2 for its child, and so on — the number of stripes drawn. 0 means none. */
|
|
16
|
+
level: number;
|
|
17
|
+
/** Which step of the ramp, 1…`FAMILY_TONES`. Every card of one family shares it. */
|
|
18
|
+
tone: number;
|
|
19
|
+
/** The root task of the family, or `null` when this card is not in one. */
|
|
20
|
+
rootId: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Which family a card belongs to, and how deep it sits in it.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **A card alone is in NO family and takes no stripes.** The pattern exists to relate cards to
|
|
27
|
+
* one another; drawn on a card that relates to nothing it says "family" where there is none, and a
|
|
28
|
+
* mark that appears on every card carries no information at all. That is a deliberate reading of
|
|
29
|
+
* "a root task has one stripe": a root WITH children has one, a card by itself has none.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ **An unknown parent is treated as no parent.** Under a filter a subtask can be in the answer
|
|
32
|
+
* while its parent is not (`board.ts` says so on `parentTaskId`), and a walk that insisted on
|
|
33
|
+
* finding it would lose the card entirely.
|
|
34
|
+
*/
|
|
35
|
+
export function familyOf(task: BoardTask, tasks: BoardTask[]): Family {
|
|
36
|
+
return familiesOf(tasks).get(task.id) ?? { level: 0, tone: 0, rootId: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every card's family, in ONE pass over the board.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **Per board, not per card.** Asking `familyOf` once per card rebuilds the index every time,
|
|
43
|
+
* which is quadratic — measured at 800 cards it cost 36 ms per render, and `dnd-kit` re-renders on
|
|
44
|
+
* every pointer move while a card is being dragged. The whole board is one answer; its families are
|
|
45
|
+
* one derivation.
|
|
46
|
+
*/
|
|
47
|
+
export function familiesOf(tasks: BoardTask[]): Map<string, Family> {
|
|
48
|
+
const byId = new Map(tasks.map((entry) => [entry.id, entry]));
|
|
49
|
+
const parents = new Set<string>();
|
|
50
|
+
for (const entry of tasks) if (entry.parentTaskId !== null) parents.add(entry.parentTaskId);
|
|
51
|
+
|
|
52
|
+
const families = new Map<string, Family>();
|
|
53
|
+
for (const task of tasks) families.set(task.id, walkFamily(task, byId, parents.has(task.id)));
|
|
54
|
+
return families;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walkFamily(task: BoardTask, byId: Map<string, BoardTask>, hasChildren: boolean): Family {
|
|
58
|
+
let level = 1;
|
|
59
|
+
let root = task;
|
|
60
|
+
const seen = new Set<string>([task.id]);
|
|
61
|
+
for (let step = 0; step < MAX_WALK; step += 1) {
|
|
62
|
+
const parentId = root.parentTaskId;
|
|
63
|
+
if (parentId === null || seen.has(parentId)) break;
|
|
64
|
+
const parent = byId.get(parentId);
|
|
65
|
+
if (parent === undefined) break;
|
|
66
|
+
seen.add(parentId);
|
|
67
|
+
root = parent;
|
|
68
|
+
level += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (level === 1 && !hasChildren) return { level: 0, tone: 0, rootId: null };
|
|
72
|
+
return { level, tone: toneOf(root.id), rootId: root.id };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* ⚠️ **Derived from the root's ID, not from its place in the list.** A position would change every
|
|
77
|
+
* time somebody drags a card, and with it every stripe on the board — the one thing a reader was
|
|
78
|
+
* meant to recognise across a whole session.
|
|
79
|
+
*/
|
|
80
|
+
export function toneOf(rootId: string): number {
|
|
81
|
+
let sum = 0;
|
|
82
|
+
for (const character of rootId) sum = (sum * 31 + character.charCodeAt(0)) % 1_000_003;
|
|
83
|
+
return (sum % FAMILY_TONES) + 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The cards of ONE column, ordered so a subtask follows its parent.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ **Threaded, not nested.** A subtask that sits in the same column as its parent is drawn
|
|
90
|
+
* directly below it, and its own children below that — depth first. A subtask whose parent is in
|
|
91
|
+
* ANOTHER column has no thread to join here and takes its ordinary place by position. The cards
|
|
92
|
+
* stay the same width with the same left edge; the relation is carried by the stripes, not by an
|
|
93
|
+
* indent that would eat the width exactly where the title is.
|
|
94
|
+
*/
|
|
95
|
+
export function threaded(cards: BoardTask[]): BoardTask[] {
|
|
96
|
+
const here = new Set(cards.map((card) => card.id));
|
|
97
|
+
const byPosition = [...cards].sort((a, b) => a.position - b.position);
|
|
98
|
+
const children = new Map<string, BoardTask[]>();
|
|
99
|
+
const roots: BoardTask[] = [];
|
|
100
|
+
|
|
101
|
+
for (const card of byPosition) {
|
|
102
|
+
const parentId = card.parentTaskId;
|
|
103
|
+
if (parentId === null || !here.has(parentId)) {
|
|
104
|
+
roots.push(card);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const siblings = children.get(parentId);
|
|
108
|
+
if (siblings === undefined) children.set(parentId, [card]);
|
|
109
|
+
else siblings.push(card);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const out: BoardTask[] = [];
|
|
113
|
+
const emitted = new Set<string>();
|
|
114
|
+
// ⚠️ A REAL guard, and against a real hole: with a loop in the parent chain (a under b, b under a,
|
|
115
|
+
// or a card under itself) neither card is a root, so the walk below never starts on them. Left at
|
|
116
|
+
// that, the column silently loses them — work falls off the board with no error anywhere.
|
|
117
|
+
const walk = (card: BoardTask) => {
|
|
118
|
+
if (emitted.has(card.id)) return;
|
|
119
|
+
emitted.add(card.id);
|
|
120
|
+
out.push(card);
|
|
121
|
+
for (const child of children.get(card.id) ?? []) walk(child);
|
|
122
|
+
};
|
|
123
|
+
for (const root of roots) walk(root);
|
|
124
|
+
// ⚠️ Whatever the walk did not reach is appended in the board's own order. A card in a loop has
|
|
125
|
+
// no sensible place in a thread, and the bottom of the column is the honest one — visible.
|
|
126
|
+
for (const card of byPosition) walk(card);
|
|
127
|
+
return out;
|
|
128
|
+
}
|