@anchrd/intel-ui 0.39.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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,14 +33,18 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.24.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
40
|
+
"@dnd-kit/core": "^6.3.1",
|
|
41
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
42
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
40
43
|
"@react-sigma/core": "^5.0.6",
|
|
41
44
|
"@tailwindcss/vite": "^4.3.3",
|
|
42
45
|
"@tanstack/react-query": "^5.101.4",
|
|
43
46
|
"@tanstack/react-router": "^1.170.18",
|
|
47
|
+
"@tanstack/react-table": "^9.1.2",
|
|
44
48
|
"@vitejs/plugin-react": "^6.0.4",
|
|
45
49
|
"@xyflow/react": "^12.11.2",
|
|
46
50
|
"class-variance-authority": "^0.7.1",
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "@/components/ui/tooltip.tsx";
|
|
12
12
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
13
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
|
-
import { useUserName } from "@/user-name/user-name.ts";
|
|
14
|
+
import { initials, useUserName } from "@/user-name/user-name.ts";
|
|
15
15
|
|
|
16
16
|
type Principal = ResourceGrant["principal"];
|
|
17
17
|
|
|
@@ -47,16 +47,6 @@ function groupGrants(grants: ResourceGrant[], ownerIds: string[] = []): GrantGro
|
|
|
47
47
|
return [...grouped.values()];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function initials(value: string): string {
|
|
51
|
-
const parts = value
|
|
52
|
-
.replace(/@.*$/, "")
|
|
53
|
-
.split(/[\s._+-]+/)
|
|
54
|
-
.filter(Boolean);
|
|
55
|
-
if (parts.length === 0) return "?";
|
|
56
|
-
if (parts.length === 1) return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
|
|
57
|
-
return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
50
|
function usePrincipalLabel(principal: Principal): string {
|
|
61
51
|
const i18n = useI18n();
|
|
62
52
|
const userName = useUserName(principal.type === "user" ? principal.id : null);
|
|
@@ -288,6 +288,43 @@ export function AppTree() {
|
|
|
288
288
|
idempotencyKey: crypto.randomUUID(),
|
|
289
289
|
});
|
|
290
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* ⚠️ **A board's columns are written at creation, in the language of whoever made it** (#675).
|
|
293
|
+
* The server has a fallback for a board nobody configured, but it cannot know the reader's
|
|
294
|
+
* language — and columns are SHARED data: two people on one board must read the same names,
|
|
295
|
+
* and the settings dialog edits one list. Translating per reader would give them different
|
|
296
|
+
* boards. Written once, they are data anybody can rename.
|
|
297
|
+
*
|
|
298
|
+
* ⚠️ It is a second call and it may fail on its own. That is survivable and deliberately not
|
|
299
|
+
* repaired here: the board then answers with the server's fallback and is usable, which is
|
|
300
|
+
* the same state a board made over MCP is in. A failure that made the whole creation fail
|
|
301
|
+
* would trade a usable board for none.
|
|
302
|
+
*/
|
|
303
|
+
if (kind === "board") {
|
|
304
|
+
try {
|
|
305
|
+
await data.updateBoard({
|
|
306
|
+
boardId: node.id,
|
|
307
|
+
columns: [
|
|
308
|
+
{ id: "backlog", title: i18n.t("board.column.default.backlog"), terminal: false },
|
|
309
|
+
{ id: "todo", title: i18n.t("board.column.default.todo"), terminal: false },
|
|
310
|
+
{ id: "doing", title: i18n.t("board.column.default.doing"), terminal: false },
|
|
311
|
+
{ id: "done", title: i18n.t("board.column.default.done"), terminal: true },
|
|
312
|
+
],
|
|
313
|
+
idempotencyKey: crypto.randomUUID(),
|
|
314
|
+
});
|
|
315
|
+
} catch {
|
|
316
|
+
/**
|
|
317
|
+
* ⚠️ **Swallowed on purpose, and the `catch` is what makes that true.** Without it the
|
|
318
|
+
* failure travels out of the mutation: the node exists, but `onSuccess` never runs, the
|
|
319
|
+
* screen says "creating failed", and the obvious response — try again — makes a SECOND
|
|
320
|
+
* board, because nothing tells the reader the first one is already there.
|
|
321
|
+
*
|
|
322
|
+
* What is lost by swallowing is one thing and it is repairable: the board answers with
|
|
323
|
+
* the server's fallback columns instead of the ones in this reader's language. That is
|
|
324
|
+
* exactly the state a board made over MCP is in, and the settings dialog renames them.
|
|
325
|
+
*/
|
|
326
|
+
}
|
|
327
|
+
}
|
|
291
328
|
return {
|
|
292
329
|
area: "/nodes" as const,
|
|
293
330
|
id: node.id,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
|
+
import { useUserName } from "@/user-name/user-name.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What to call whoever a card is for.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **One rule, two surfaces.** The table draws the name in a cell, the open card draws it in a
|
|
8
|
+
* circle, and both must answer the same way — `useUserName` resolves only the reader themselves, so
|
|
9
|
+
* for anybody else the honest answer is "a user account" and never the principal id the row carries
|
|
10
|
+
* (`user-name.ts`, #258). Written twice, the two would drift, and the drift would put an id on one
|
|
11
|
+
* of the two screens.
|
|
12
|
+
*/
|
|
13
|
+
export function useAssigneeLabel(id: string | null): string {
|
|
14
|
+
const i18n = useI18n();
|
|
15
|
+
const name = useUserName(id);
|
|
16
|
+
if (id === null) return i18n.t("board.unassigned");
|
|
17
|
+
return name ?? i18n.t("node.someUser");
|
|
18
|
+
}
|
|
@@ -6,8 +6,11 @@ import type {
|
|
|
6
6
|
BoardTaskUpdateInput,
|
|
7
7
|
BoardView,
|
|
8
8
|
} from "@anchrd/intel-contract/board";
|
|
9
|
+
import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
|
|
9
10
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
10
11
|
import { useMemo } from "react";
|
|
12
|
+
import { threaded } from "@/board/board-stripes/board-stripes.ts";
|
|
13
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
11
14
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
12
15
|
import type { BoardHandle, ColumnWithCards } from "./board-data.types.ts";
|
|
13
16
|
|
|
@@ -58,7 +61,10 @@ export function columnsWithCards(view: BoardView): ColumnWithCards[] {
|
|
|
58
61
|
if (cards === undefined) byStatus.set(task.status, [task]);
|
|
59
62
|
else cards.push(task);
|
|
60
63
|
}
|
|
61
|
-
|
|
64
|
+
// ⚠️ Threaded, not merely sorted: a subtask that sits in the SAME column as its parent follows
|
|
65
|
+
// it directly, and its own children follow that. One that sits elsewhere takes its ordinary place
|
|
66
|
+
// by position — there is no thread here for it to join (`board-stripes.ts`).
|
|
67
|
+
const sorted = (cards: BoardTask[]) => threaded(cards);
|
|
62
68
|
const known: ColumnWithCards[] = view.columns.map((column) => ({
|
|
63
69
|
column,
|
|
64
70
|
cards: sorted(byStatus.get(column.id) ?? []),
|
|
@@ -85,6 +91,7 @@ export function columnsWithCards(view: BoardView): ColumnWithCards[] {
|
|
|
85
91
|
*/
|
|
86
92
|
export function useBoard(boardId: string, filter: Partial<BoardTaskFilter> = {}): BoardHandle {
|
|
87
93
|
const { data } = useIntelRouterContext();
|
|
94
|
+
const i18n = useI18n();
|
|
88
95
|
const client = useQueryClient();
|
|
89
96
|
const key = boardKey(boardId);
|
|
90
97
|
|
|
@@ -136,19 +143,38 @@ export function useBoard(boardId: string, filter: Partial<BoardTaskFilter> = {})
|
|
|
136
143
|
onSuccess: settle,
|
|
137
144
|
});
|
|
138
145
|
const setColumns = useMutation({
|
|
139
|
-
mutationFn: async (input: {
|
|
140
|
-
|
|
146
|
+
mutationFn: async (input: {
|
|
147
|
+
columns: BoardColumn[];
|
|
148
|
+
archiveVisible?: boolean;
|
|
149
|
+
idempotencyKey: string;
|
|
150
|
+
}) => await data.updateBoard({ boardId, ...input }),
|
|
141
151
|
onSuccess: settle,
|
|
142
152
|
});
|
|
143
153
|
|
|
154
|
+
/**
|
|
155
|
+
* ⚠️ **The archive column is said in the reader's own words, here and nowhere else.** It is
|
|
156
|
+
* derived and identical on every board, so unlike a configured column — shared data, written
|
|
157
|
+
* once in the creator's language (#675) — it is presentation. The server sends English for
|
|
158
|
+
* everything without a catalog; this is the one place a browser has one.
|
|
159
|
+
*/
|
|
144
160
|
const columns = useMemo(
|
|
145
|
-
() =>
|
|
146
|
-
|
|
161
|
+
() =>
|
|
162
|
+
query.data === undefined
|
|
163
|
+
? []
|
|
164
|
+
: columnsWithCards(query.data).map((entry) =>
|
|
165
|
+
entry.column.id === ARCHIVE_COLUMN_ID
|
|
166
|
+
? { ...entry, column: { ...entry.column, title: i18n.t("board.column.archive") } }
|
|
167
|
+
: entry,
|
|
168
|
+
),
|
|
169
|
+
[query.data, i18n],
|
|
147
170
|
);
|
|
148
171
|
|
|
149
172
|
return {
|
|
150
173
|
boardId,
|
|
151
174
|
title: query.data?.title ?? "",
|
|
175
|
+
// Until the board has answered there is nothing to hide, and `true` is what a board without a
|
|
176
|
+
// stored setting answers with anyway.
|
|
177
|
+
archiveVisible: query.data?.archiveVisible ?? true,
|
|
152
178
|
columns,
|
|
153
179
|
isPending: query.isPending,
|
|
154
180
|
isError: query.isError,
|
|
@@ -25,5 +25,14 @@ export interface BoardHandle {
|
|
|
25
25
|
// forgetting a field.
|
|
26
26
|
createTask(input: Omit<BoardTaskCreateInput, "boardId">): Promise<void>;
|
|
27
27
|
updateTask(input: BoardTaskUpdateInput): Promise<void>;
|
|
28
|
-
|
|
28
|
+
/** Whether the archive column is drawn (D68). The settings dialog draws the switch in both
|
|
29
|
+
* positions, so it needs the current value, not only the column list. */
|
|
30
|
+
archiveVisible: boolean;
|
|
31
|
+
setColumns(input: {
|
|
32
|
+
columns: BoardColumn[];
|
|
33
|
+
// ⚠️ Optional means "leave it": the dialog writes the columns and the switch in ONE call, and a
|
|
34
|
+
// caller who only reorders must not un-hide an archive somebody put away.
|
|
35
|
+
archiveVisible?: boolean;
|
|
36
|
+
idempotencyKey: string;
|
|
37
|
+
}): Promise<void>;
|
|
29
38
|
}
|
|
@@ -93,16 +93,70 @@ export function neighbourDrop(
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
*
|
|
96
|
+
* What a finished drag means as a `Drop` — translated from what `dnd-kit` reports.
|
|
97
97
|
*
|
|
98
|
-
* ⚠️
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
98
|
+
* ⚠️ **`over` is either a CARD or a COLUMN, and the two mean different things.** Let go over a
|
|
99
|
+
* card, the gesture means "into that card's gap"; let go over the column itself — the free area
|
|
100
|
+
* below the last card — it means "append". Reading both as the same thing is how every drop ends
|
|
101
|
+
* up at the bottom of the column, which looks like a broken drag rather than like a missing case.
|
|
102
102
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
103
|
+
* ⚠️ **`after` is only consulted ACROSS columns.** Inside one column the direction of travel
|
|
104
|
+
* already says which gap is meant; between columns there is none, so which side of the hovered card
|
|
105
|
+
* the CARRIED card came to rest on decides — see `dropIsAfter`.
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ **Downwards inside one column, the gap is one BEHIND the card hovered.** `Drop.index` counts
|
|
108
|
+
* into the column as rendered, with the dragged card still in it. Dragging a card down onto a
|
|
109
|
+
* neighbour means ending up behind that neighbour; naming the neighbour's own gap names the gap the
|
|
110
|
+
* card already occupies, `dropToWrite` then correctly answers "nothing changed", and the move is
|
|
111
|
+
* swallowed without a word. Upwards the two coincide, which is why this reads as an asymmetry and
|
|
112
|
+
* is not one.
|
|
113
|
+
*/
|
|
114
|
+
export function dragEndToDrop(
|
|
115
|
+
activeId: string,
|
|
116
|
+
overId: string | null,
|
|
117
|
+
columns: ColumnWithCards[],
|
|
118
|
+
after = false,
|
|
119
|
+
): Drop | null {
|
|
120
|
+
if (overId === null || overId === activeId) return null;
|
|
121
|
+
|
|
122
|
+
const asColumn = columns.find((entry) => entry.column.id === overId);
|
|
123
|
+
if (asColumn !== undefined) return { status: asColumn.column.id, index: asColumn.cards.length };
|
|
124
|
+
|
|
125
|
+
for (const entry of columns) {
|
|
126
|
+
const row = entry.cards.findIndex((card) => card.id === overId);
|
|
127
|
+
if (row < 0) continue;
|
|
128
|
+
const from = entry.cards.findIndex((card) => card.id === activeId);
|
|
129
|
+
// Inside one column the direction of travel already says it: downwards means behind.
|
|
130
|
+
if (from >= 0) return { status: entry.column.id, index: from < row ? row + 1 : row };
|
|
131
|
+
// ⚠️ Across columns there is no direction to read, so the half of the card decides — and
|
|
132
|
+
// without it the END of a foreign column is reachable only through the thin strip below its
|
|
133
|
+
// last card. That is the capability the half-of-the-card check carried before `dnd-kit`.
|
|
134
|
+
return { status: entry.column.id, index: after ? row + 1 : row };
|
|
135
|
+
}
|
|
136
|
+
// A card that is on no column of this board — an answer that arrived while the gesture ran.
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A box, as far as this decision cares: where its top is and how tall it is. */
|
|
141
|
+
export interface DropBox {
|
|
142
|
+
top: number;
|
|
143
|
+
height: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a carried card came to rest BELOW the middle of the card it was let go on.
|
|
148
|
+
*
|
|
149
|
+
* ⚠️ **This compares two CARDS, not a pointer against a card.** The gesture reports where the
|
|
150
|
+
* carried card ended up, not where the cursor is, and on cards of different heights those are two
|
|
151
|
+
* different answers. Naming it after the pointer would send the next reader looking for a
|
|
152
|
+
* coordinate that is not in this code.
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ **A missing box means "before".** `translated` is `null` until the drag has actually moved
|
|
155
|
+
* something, and in jsdom every box is zero — so the fallback is the one that has to be named
|
|
156
|
+
* rather than fallen into: no measurement is not "at the end", it is "no information", and the
|
|
157
|
+
* front of the gap is where a card goes when nothing is known.
|
|
105
158
|
*/
|
|
106
|
-
export function
|
|
107
|
-
|
|
159
|
+
export function dropIsAfter(carried: DropBox | null, over: DropBox | undefined): boolean {
|
|
160
|
+
if (carried === null || over === undefined) return false;
|
|
161
|
+
return carried.top + carried.height / 2 > over.top + over.height / 2;
|
|
108
162
|
}
|