@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.38.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.23.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
- const sorted = (cards: BoardTask[]) => [...cards].sort((a, b) => a.position - b.position);
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: { columns: BoardColumn[]; idempotencyKey: string }) =>
140
- await data.updateBoard({ boardId, ...input }),
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
- () => (query.data === undefined ? [] : columnsWithCards(query.data)),
146
- [query.data],
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
- setColumns(input: { columns: BoardColumn[]; idempotencyKey: string }): Promise<void>;
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
- * Which gap a drop ON a card means the half of the card the pointer is over.
96
+ * What a finished drag means as a `Drop` translated from what `dnd-kit` reports.
97
97
  *
98
- * ⚠️ Without this the column is the only drop target there is, and every drop then means "append":
99
- * `dropToWrite` computes a correct midpoint for any gap, but the component never asks it for one
100
- * other than the end. On screen the card follows the pointer to the middle of the column and then
101
- * jumps to the bottom, which reads as a broken drag rather than as a missing feature.
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
- * The geometry is passed in rather than read here so the decision upper half means before the
104
- * card, lower half means after it can be proven without a layout engine.
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 dropIndexOnCard(row: number, pointerY: number, box: DOMRect): number {
107
- return pointerY < box.top + box.height / 2 ? row : row + 1;
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
  }