@anchrd/intel-ui 0.36.0 → 0.38.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 +2 -2
- package/src/app/app-tree/app-tree.tsx +15 -6
- package/src/app/view-toggle/view-toggle.tsx +7 -2
- package/src/board/board-data/board-data.ts +169 -0
- package/src/board/board-data/board-data.types.ts +29 -0
- package/src/board/board-kanban/board-kanban.ts +108 -0
- package/src/board/board-kanban/board-kanban.tsx +295 -0
- package/src/board/board-panel/board-panel.tsx +74 -0
- package/src/board/board-table/board-table.ts +135 -0
- package/src/board/board-table/board-table.tsx +199 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +50 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +22 -0
- package/src/document-link/document-link.tsx +0 -18
- package/src/editor-schema/editor-schema.ts +21 -0
- package/src/flows/flows.tsx +11 -1
- package/src/frontmatter/frontmatter-markdown.ts +63 -0
- package/src/frontmatter/frontmatter.tsx +184 -0
- package/src/i18n/de.json +389 -363
- package/src/i18n/en.json +389 -363
- package/src/i18n/es.json +389 -363
- package/src/kind-icon.ts +17 -1
- package/src/node-editor/node-editor.tsx +37 -29
- package/src/nodes/nodes.tsx +3 -0
- package/src/resource-menu/resource-menu.tsx +12 -38
- package/src/title-row/title-row.tsx +12 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { BoardHandle, ColumnWithCards } from "@/board/board-data/board-data.types.ts";
|
|
4
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
|
+
import { type Drop, dropIndexOnCard, dropToWrite, neighbourDrop } from "./board-kanban.ts";
|
|
6
|
+
|
|
7
|
+
// The id of the card being dragged. `getData` is the payload the browser carries for us; nothing
|
|
8
|
+
// else about the gesture needs to survive a re-render, so there is no state here.
|
|
9
|
+
const CARRIED = "text/plain";
|
|
10
|
+
|
|
11
|
+
function Card({
|
|
12
|
+
card,
|
|
13
|
+
column,
|
|
14
|
+
row,
|
|
15
|
+
board,
|
|
16
|
+
onMove,
|
|
17
|
+
}: {
|
|
18
|
+
card: BoardTask;
|
|
19
|
+
column: ColumnWithCards;
|
|
20
|
+
row: number;
|
|
21
|
+
board: BoardHandle;
|
|
22
|
+
onMove(card: BoardTask, drop: Drop): void;
|
|
23
|
+
}) {
|
|
24
|
+
const i18n = useI18n();
|
|
25
|
+
const locked = board.isWriting;
|
|
26
|
+
return (
|
|
27
|
+
<li
|
|
28
|
+
onDragOver={(event) => {
|
|
29
|
+
if (locked) return;
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
event.dataTransfer.dropEffect = "move";
|
|
32
|
+
}}
|
|
33
|
+
onDrop={(event) => {
|
|
34
|
+
if (locked) return;
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
// ⚠️ The column below is a drop target too, and it means "append". Letting this event reach
|
|
37
|
+
// it would overwrite the gap just measured with the end of the column — the bug this
|
|
38
|
+
// handler exists to fix, restored by the bubbling.
|
|
39
|
+
event.stopPropagation();
|
|
40
|
+
const dragged = cardById(board, event.dataTransfer.getData(CARRIED));
|
|
41
|
+
if (dragged === undefined) return;
|
|
42
|
+
const index = dropIndexOnCard(
|
|
43
|
+
row,
|
|
44
|
+
event.clientY,
|
|
45
|
+
event.currentTarget.getBoundingClientRect(),
|
|
46
|
+
);
|
|
47
|
+
onMove(dragged, { status: column.column.id, index });
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{/* ⚠️ A real button, not an `<li role="button">`. The role override needs two lint
|
|
51
|
+
suppressions and still leaves a listitem that only *claims* to be operable; a button is
|
|
52
|
+
focusable, is announced as operable, and — the part that made the override look necessary
|
|
53
|
+
— may perfectly well be a drag source. */}
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
draggable={!locked}
|
|
57
|
+
aria-label={i18n.t("board.cardLabel", { title: card.title, column: column.column.title })}
|
|
58
|
+
className="w-full cursor-grab rounded-md border border-border bg-card p-2 text-left text-sm shadow-xs focus-visible:outline-2 focus-visible:outline-ring"
|
|
59
|
+
onDragStart={(event) => {
|
|
60
|
+
event.dataTransfer.setData(CARRIED, card.id);
|
|
61
|
+
event.dataTransfer.effectAllowed = "move";
|
|
62
|
+
}}
|
|
63
|
+
onKeyDown={(event) => {
|
|
64
|
+
// ⚠️ Not while a write is in flight. Two moves in quick succession both read the same
|
|
65
|
+
// not-yet-updated `board.columns`, compute a position independently and both write — the
|
|
66
|
+
// second answer overwrites the first, with no error, because both succeeded. That is the
|
|
67
|
+
// race `isWriting` was introduced for in #651, and marking the board busy without
|
|
68
|
+
// refusing the input leaves it exactly as open as before.
|
|
69
|
+
if (locked) return;
|
|
70
|
+
// ⚠️ Only with a modifier. The arrows alone belong to whatever the reader is used to —
|
|
71
|
+
// scrolling the column, moving focus — and taking them would make a board that cannot be
|
|
72
|
+
// read without moving its cards.
|
|
73
|
+
if (!event.altKey) return;
|
|
74
|
+
const direction =
|
|
75
|
+
event.key === "ArrowLeft"
|
|
76
|
+
? "left"
|
|
77
|
+
: event.key === "ArrowRight"
|
|
78
|
+
? "right"
|
|
79
|
+
: event.key === "ArrowUp"
|
|
80
|
+
? "up"
|
|
81
|
+
: event.key === "ArrowDown"
|
|
82
|
+
? "down"
|
|
83
|
+
: null;
|
|
84
|
+
if (direction === null) return;
|
|
85
|
+
const drop = neighbourDrop(card, board.columns, direction);
|
|
86
|
+
if (drop === null) return;
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
onMove(card, drop);
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<span className="block">{card.title}</span>
|
|
92
|
+
{card.dueDate === null ? null : (
|
|
93
|
+
<span className="mt-1 block text-xs text-muted-foreground">
|
|
94
|
+
{card.dueDate.slice(0, 10)}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
</button>
|
|
98
|
+
</li>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cardById(board: BoardHandle, id: string): BoardTask | undefined {
|
|
103
|
+
return board.columns.flatMap((entry) => entry.cards).find((card) => card.id === id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One column, with the way to add a card to it.
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ **`group/column` sits on the section, and that is only safe because columns do not nest.**
|
|
110
|
+
* A named group compiles to a DESCENDANT selector: put it on something that also holds another
|
|
111
|
+
* column and hovering one card lights the add row of every column below it — the tree paid for that
|
|
112
|
+
* with #252, and `packages/ui/CLAUDE.md` carries the rule. Here the sections are siblings, so a
|
|
113
|
+
* card in one is never a descendant of another. `board-kanban.unit.tsx` asserts exactly that,
|
|
114
|
+
* structurally, because jsdom computes no hover.
|
|
115
|
+
*/
|
|
116
|
+
function Column({
|
|
117
|
+
entry,
|
|
118
|
+
board,
|
|
119
|
+
onMove,
|
|
120
|
+
}: {
|
|
121
|
+
entry: ColumnWithCards;
|
|
122
|
+
board: BoardHandle;
|
|
123
|
+
onMove(card: BoardTask, drop: Drop): void;
|
|
124
|
+
}) {
|
|
125
|
+
const i18n = useI18n();
|
|
126
|
+
const [composing, setComposing] = useState(false);
|
|
127
|
+
const [draft, setDraft] = useState("");
|
|
128
|
+
const [failed, setFailed] = useState(false);
|
|
129
|
+
// ⚠️ A column the board does not configure takes no new card: the server refuses a status that
|
|
130
|
+
// names no column (`unknown_column`), so offering the control would promise a refusal.
|
|
131
|
+
const addable = entry.unknown !== true;
|
|
132
|
+
|
|
133
|
+
// ⚠️ One way out, and it clears the REFUSAL too. Left standing, the message outlives the attempt
|
|
134
|
+
// it belongs to: somebody types, is refused, presses Escape — and the column keeps a sentence
|
|
135
|
+
// about a card nobody is trying to create any more, until the next attempt happens to clear it.
|
|
136
|
+
const close = () => {
|
|
137
|
+
setComposing(false);
|
|
138
|
+
setDraft("");
|
|
139
|
+
setFailed(false);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const create = () => {
|
|
143
|
+
const title = draft.trim();
|
|
144
|
+
if (title.length === 0 || board.isWriting) return;
|
|
145
|
+
setFailed(false);
|
|
146
|
+
board
|
|
147
|
+
.createTask({
|
|
148
|
+
title,
|
|
149
|
+
status: entry.column.id,
|
|
150
|
+
// ⚠️ Spelled out because the handle takes the contract's OUTPUT type, where every default
|
|
151
|
+
// has already been applied and the fields are therefore required. The server decides the
|
|
152
|
+
// position — the end of this column — and a retry does not move the card again (#648).
|
|
153
|
+
assigneeId: null,
|
|
154
|
+
labels: [],
|
|
155
|
+
startDate: null,
|
|
156
|
+
dueDate: null,
|
|
157
|
+
dependsOn: null,
|
|
158
|
+
idempotencyKey: crypto.randomUUID(),
|
|
159
|
+
})
|
|
160
|
+
.then(close)
|
|
161
|
+
// ⚠️ Reported IN the column. A refusal shown only in a global banner leaves the reader
|
|
162
|
+
// looking at the column they typed into, which says nothing happened.
|
|
163
|
+
.catch(() => setFailed(true));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<section
|
|
168
|
+
aria-label={entry.column.title}
|
|
169
|
+
className="group/column flex w-64 shrink-0 flex-col gap-2"
|
|
170
|
+
onDragOver={(event) => {
|
|
171
|
+
if (board.isWriting) return;
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
event.dataTransfer.dropEffect = "move";
|
|
174
|
+
}}
|
|
175
|
+
onDrop={(event) => {
|
|
176
|
+
if (board.isWriting) return;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
const dragged = cardById(board, event.dataTransfer.getData(CARRIED));
|
|
179
|
+
// ⚠️ Let go on the column itself rather than on one of its cards: the end of it. A card
|
|
180
|
+
// drop stops the event before it arrives here, so this is the free area below the last
|
|
181
|
+
// card — and there, "append" is what the gesture means.
|
|
182
|
+
if (dragged !== undefined) {
|
|
183
|
+
onMove(dragged, { status: entry.column.id, index: entry.cards.length });
|
|
184
|
+
}
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<h3 className="flex items-baseline gap-2 px-1 text-sm font-medium">
|
|
188
|
+
{entry.column.title}
|
|
189
|
+
<span className="text-xs text-muted-foreground tabular-nums">{entry.cards.length}</span>
|
|
190
|
+
{entry.unknown === true ? (
|
|
191
|
+
// ⚠️ A column the board does not configure — cards whose status names nothing. Drawn
|
|
192
|
+
// rather than hidden: a card nobody can see is a card somebody will look for.
|
|
193
|
+
<span className="text-xs text-muted-foreground">{i18n.t("board.unknownColumn")}</span>
|
|
194
|
+
) : null}
|
|
195
|
+
{addable ? (
|
|
196
|
+
// ⚠️ The narrow-screen way in, and it is ALWAYS visible: hover is not a gesture a touch
|
|
197
|
+
// screen has, so the row below would be unreachable there. Above `md` this one steps
|
|
198
|
+
// aside for it.
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
aria-label={i18n.t("board.addCardIn", { column: entry.column.title })}
|
|
202
|
+
onClick={() => setComposing(true)}
|
|
203
|
+
className="ml-auto rounded-md px-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring md:hidden"
|
|
204
|
+
>
|
|
205
|
+
+
|
|
206
|
+
</button>
|
|
207
|
+
) : null}
|
|
208
|
+
</h3>
|
|
209
|
+
<ul className="flex flex-col gap-2">
|
|
210
|
+
{entry.cards.map((card, row) => (
|
|
211
|
+
<Card key={card.id} card={card} column={entry} row={row} board={board} onMove={onMove} />
|
|
212
|
+
))}
|
|
213
|
+
</ul>
|
|
214
|
+
{entry.cards.length === 0 ? (
|
|
215
|
+
<p className="px-1 text-xs text-muted-foreground">{i18n.t("board.columnEmpty")}</p>
|
|
216
|
+
) : null}
|
|
217
|
+
{addable ? (
|
|
218
|
+
composing ? (
|
|
219
|
+
<input
|
|
220
|
+
// The field exists because it was just asked for, and making the reader click a second
|
|
221
|
+
// time is the whole cost of the control.
|
|
222
|
+
// biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
|
|
223
|
+
autoFocus
|
|
224
|
+
value={draft}
|
|
225
|
+
aria-label={i18n.t("board.addCardIn", { column: entry.column.title })}
|
|
226
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
227
|
+
onKeyDown={(event) => {
|
|
228
|
+
if (event.key === "Enter") {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
create();
|
|
231
|
+
}
|
|
232
|
+
if (event.key === "Escape") close();
|
|
233
|
+
}}
|
|
234
|
+
onBlur={() => {
|
|
235
|
+
if (draft.trim().length === 0) close();
|
|
236
|
+
}}
|
|
237
|
+
className="rounded-md border bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
238
|
+
/>
|
|
239
|
+
) : (
|
|
240
|
+
// ⚠️ `md:opacity-0` plus BOTH group states, not `hover` alone: a control that only exists
|
|
241
|
+
// while a pointer rests on the column does not exist for a keyboard at all. Focus inside
|
|
242
|
+
// the column brings it back, the same shape the tree uses for its plus.
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={() => setComposing(true)}
|
|
246
|
+
className="hidden rounded-md px-2 py-1 text-left text-sm text-muted-foreground outline-none transition-opacity hover:bg-muted focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-focus-within/column:opacity-100 group-hover/column:opacity-100 md:block md:opacity-0"
|
|
247
|
+
>
|
|
248
|
+
{i18n.t("board.addCard")}
|
|
249
|
+
</button>
|
|
250
|
+
)
|
|
251
|
+
) : null}
|
|
252
|
+
{failed ? (
|
|
253
|
+
<p role="alert" className="px-1 text-xs text-destructive">
|
|
254
|
+
{i18n.t("board.createFailed")}
|
|
255
|
+
</p>
|
|
256
|
+
) : null}
|
|
257
|
+
</section>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function BoardKanban({ board }: { board: BoardHandle }) {
|
|
262
|
+
const i18n = useI18n();
|
|
263
|
+
|
|
264
|
+
if (board.isPending) {
|
|
265
|
+
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
266
|
+
}
|
|
267
|
+
if (board.isError) {
|
|
268
|
+
return (
|
|
269
|
+
<p role="alert" className="p-6 text-sm text-destructive">
|
|
270
|
+
{i18n.t("board.failed")}
|
|
271
|
+
</p>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const move = (card: BoardTask, drop: Drop) => {
|
|
276
|
+
const write = dropToWrite(card, board.columns, drop);
|
|
277
|
+
// ⚠️ `null` means the card is where it already was. Writing anyway would recompute the position
|
|
278
|
+
// and make it jump — the most common "drop" there is somebody putting a card back.
|
|
279
|
+
if (write === null) return;
|
|
280
|
+
void board.updateTask({
|
|
281
|
+
taskId: card.id,
|
|
282
|
+
status: write.status,
|
|
283
|
+
position: write.position,
|
|
284
|
+
idempotencyKey: `move-${card.id}-${write.status}-${write.position}`,
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div className="flex gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
|
|
290
|
+
{board.columns.map((column) => (
|
|
291
|
+
<Column key={column.column.id} entry={column} board={board} onMove={move} />
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { BoardTaskFilter } from "@anchrd/intel-contract/board";
|
|
2
|
+
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useBoard } from "@/board/board-data/board-data.ts";
|
|
5
|
+
import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
|
|
6
|
+
import type { GroupKey, Sort } from "@/board/board-table/board-table.ts";
|
|
7
|
+
import { BoardTable } from "@/board/board-table/board-table.tsx";
|
|
8
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
9
|
+
|
|
10
|
+
type View = "kanban" | "table";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A board on the node screen, beside the folder, the table and the editor (D66, #376).
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ 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. A board
|
|
17
|
+
* that loaded its cards one by one would look exactly the same on screen and be a different thing
|
|
18
|
+
* over the wire, which is why `board-panel.unit.tsx` counts the calls rather than the cards.
|
|
19
|
+
*/
|
|
20
|
+
export function BoardPanel({ node }: { node: Node }) {
|
|
21
|
+
const i18n = useI18n();
|
|
22
|
+
const [view, setView] = useState<View>("kanban");
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
25
|
+
{/* No wrapper role: the house switch (`view-toggle.tsx`) carries none either, and a `group`
|
|
26
|
+
on a plain `div` is the shape a linter rightly asks to be a `fieldset`. What names the
|
|
27
|
+
control is each button — its own text, plus `aria-pressed` for which view is showing. */}
|
|
28
|
+
<div className="flex shrink-0 items-center gap-1 border-b px-4 py-2">
|
|
29
|
+
{(["kanban", "table"] as const).map((name) => (
|
|
30
|
+
<button
|
|
31
|
+
key={name}
|
|
32
|
+
type="button"
|
|
33
|
+
aria-pressed={view === name}
|
|
34
|
+
onClick={() => setView(name)}
|
|
35
|
+
className="rounded-md px-3 py-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted aria-pressed:font-medium"
|
|
36
|
+
>
|
|
37
|
+
{i18n.t(`board.view.${name}`)}
|
|
38
|
+
</button>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
{view === "kanban" ? <KanbanView nodeId={node.id} /> : <TableView nodeId={node.id} />}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function KanbanView({ nodeId }: { nodeId: string }) {
|
|
47
|
+
const board = useBoard(nodeId);
|
|
48
|
+
return <BoardKanban board={board} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ⚠️ **The filter lives HERE, inside the table's own branch, not beside both views.** Held one
|
|
53
|
+
* level up it would survive a switch to the kanban board — which draws whatever the query answered
|
|
54
|
+
* and would then show a column full and the rest empty, with nothing on screen saying why. That is
|
|
55
|
+
* the quiet failure: cards that look deleted. Unmounting the table takes its filter with it, and
|
|
56
|
+
* the kanban asks for the whole board every time.
|
|
57
|
+
*/
|
|
58
|
+
function TableView({ nodeId }: { nodeId: string }) {
|
|
59
|
+
const [filter, setFilter] = useState<Partial<BoardTaskFilter>>({});
|
|
60
|
+
const [sort, setSort] = useState<Sort | null>(null);
|
|
61
|
+
const [group, setGroup] = useState<GroupKey>("none");
|
|
62
|
+
const board = useBoard(nodeId, filter);
|
|
63
|
+
return (
|
|
64
|
+
<BoardTable
|
|
65
|
+
board={board}
|
|
66
|
+
filter={filter}
|
|
67
|
+
onFilter={setFilter}
|
|
68
|
+
sort={sort}
|
|
69
|
+
onSort={setSort}
|
|
70
|
+
group={group}
|
|
71
|
+
onGroup={setGroup}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
+
import type { ColumnWithCards } from "@/board/board-data/board-data.types.ts";
|
|
3
|
+
|
|
4
|
+
export type SortKey = "title" | "status" | "assignee" | "due" | "dependsOn";
|
|
5
|
+
export type GroupKey = "none" | "status" | "assignee";
|
|
6
|
+
|
|
7
|
+
export interface Sort {
|
|
8
|
+
key: SortKey;
|
|
9
|
+
direction: "asc" | "desc";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One line of the table: the card, plus the two things the card cannot answer about itself.
|
|
14
|
+
*
|
|
15
|
+
* `columnTitle` is its status resolved against the board's configuration, and `blocking` is what
|
|
16
|
+
* `dependsOn` points at — the blocking card's title when it is on this board, its bare id when it
|
|
17
|
+
* is not. ⚠️ **The id is shown rather than hidden**: a dependency on something outside this board
|
|
18
|
+
* is exactly the case somebody needs to see, and an empty cell claims there is no dependency.
|
|
19
|
+
*/
|
|
20
|
+
export interface Row {
|
|
21
|
+
task: BoardTask;
|
|
22
|
+
columnTitle: string;
|
|
23
|
+
blocking: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rowsOf(columns: ColumnWithCards[]): Row[] {
|
|
27
|
+
const titles = new Map<string, string>();
|
|
28
|
+
for (const entry of columns) {
|
|
29
|
+
for (const card of entry.cards) titles.set(card.id, card.title);
|
|
30
|
+
}
|
|
31
|
+
return columns.flatMap((entry) =>
|
|
32
|
+
entry.cards.map((task) => ({
|
|
33
|
+
task,
|
|
34
|
+
columnTitle: entry.column.title,
|
|
35
|
+
blocking: task.dependsOn === null ? null : (titles.get(task.dependsOn) ?? task.dependsOn),
|
|
36
|
+
})),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cellOf(row: Row, key: SortKey): string | null {
|
|
41
|
+
switch (key) {
|
|
42
|
+
case "title":
|
|
43
|
+
return row.task.title;
|
|
44
|
+
case "status":
|
|
45
|
+
return row.columnTitle;
|
|
46
|
+
case "assignee":
|
|
47
|
+
return row.task.assigneeId;
|
|
48
|
+
case "due":
|
|
49
|
+
return row.task.dueDate;
|
|
50
|
+
case "dependsOn":
|
|
51
|
+
return row.blocking;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The table in one order.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ **An empty cell sorts last in BOTH directions, and that is not a preference.** A card without
|
|
59
|
+
* a due date is not the earliest one — it is one whose date nobody knows, and putting it first is a
|
|
60
|
+
* table that answers "what is due next" with the rows that have no answer. Comparing `null` with a
|
|
61
|
+
* string instead is worse than wrong: `null < "2026-08-19"` and `null > "2026-08-19"` are BOTH
|
|
62
|
+
* false, so the comparator claims equality between rows that are not equal, and the order then
|
|
63
|
+
* depends on where the dateless rows happened to sit.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ **The sort is stable and the incoming order is the tiebreaker.** Rows arrive in the board's
|
|
66
|
+
* own order — configured columns first, cards by position — so two cards with the same due date
|
|
67
|
+
* keep the order the board gives them rather than swapping on every render.
|
|
68
|
+
*/
|
|
69
|
+
export function sortRows(rows: Row[], sort: Sort | null): Row[] {
|
|
70
|
+
if (sort === null) return rows;
|
|
71
|
+
const factor = sort.direction === "asc" ? 1 : -1;
|
|
72
|
+
return [...rows].sort((left, right) => {
|
|
73
|
+
const a = cellOf(left, sort.key);
|
|
74
|
+
const b = cellOf(right, sort.key);
|
|
75
|
+
if (a === null && b === null) return 0;
|
|
76
|
+
if (a === null) return 1;
|
|
77
|
+
if (b === null) return -1;
|
|
78
|
+
// An ISO timestamp compares correctly as a string, so one comparison serves every column.
|
|
79
|
+
return factor * a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface Group {
|
|
84
|
+
/**
|
|
85
|
+
* The VALUE the rows were grouped by, not the text to draw.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ The difference matters for the assignee: the value is a Gate principal id, and an id must
|
|
88
|
+
* never reach the screen (`user-name.ts`, #258). The view resolves it to a name or to the word
|
|
89
|
+
* for somebody it cannot name; this module has no business deciding that, and returning a
|
|
90
|
+
* ready-made label would have made the id the obvious thing to put there.
|
|
91
|
+
*
|
|
92
|
+
* `null` is the group the key does not answer for — unassigned, mostly.
|
|
93
|
+
*/
|
|
94
|
+
key: string | null;
|
|
95
|
+
rows: Row[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The rows in groups, in the order the rows themselves arrive.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ **Not alphabetically.** Grouped by status, the groups are the board's columns, and their order
|
|
102
|
+
* carries meaning the alphabet destroys: `todo, doing, done` would be drawn `doing, done, todo`.
|
|
103
|
+
* Walking the rows and appending each new group as it first appears keeps the board's order for
|
|
104
|
+
* free — and keeps whatever `sortRows` decided when the grouping key is something else.
|
|
105
|
+
*/
|
|
106
|
+
export function groupRows(rows: Row[], key: GroupKey): Group[] {
|
|
107
|
+
if (key === "none") return [{ key: null, rows }];
|
|
108
|
+
const groups: Group[] = [];
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
const value = key === "status" ? row.columnTitle : row.task.assigneeId;
|
|
111
|
+
const existing = groups.find((group) => group.key === value);
|
|
112
|
+
if (existing === undefined) groups.push({ key: value, rows: [row] });
|
|
113
|
+
else existing.rows.push(row);
|
|
114
|
+
}
|
|
115
|
+
return groups;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What the header of a sortable column has to say for a reader who cannot see the arrow. */
|
|
119
|
+
export function ariaSortOf(sort: Sort | null, key: SortKey): "ascending" | "descending" | "none" {
|
|
120
|
+
if (sort === null || sort.key !== key) return "none";
|
|
121
|
+
return sort.direction === "asc" ? "ascending" : "descending";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What a click on a header means.
|
|
126
|
+
*
|
|
127
|
+
* ⚠️ Three states, not two: ascending, descending, and back to the board's own order. Without the
|
|
128
|
+
* third there is no way back — once a table is sorted it stays sorted, and the order the board was
|
|
129
|
+
* arranged in by hand is unreachable for the rest of the session.
|
|
130
|
+
*/
|
|
131
|
+
export function nextSort(sort: Sort | null, key: SortKey): Sort | null {
|
|
132
|
+
if (sort === null || sort.key !== key) return { key, direction: "asc" };
|
|
133
|
+
if (sort.direction === "asc") return { key, direction: "desc" };
|
|
134
|
+
return null;
|
|
135
|
+
}
|