@anchrd/intel-ui 0.37.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/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/frontmatter/frontmatter-markdown.ts +63 -0
- package/src/frontmatter/frontmatter.tsx +184 -0
- package/src/i18n/de.json +389 -362
- package/src/i18n/en.json +389 -362
- package/src/i18n/es.json +389 -362
- package/src/kind-icon.ts +17 -1
- package/src/node-editor/node-editor.tsx +37 -29
- package/src/nodes/nodes.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.23.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -47,12 +47,17 @@ import { selectedFrom } from "@/router/selection-search.ts";
|
|
|
47
47
|
/**
|
|
48
48
|
* What the plus can MAKE — derived from `NodeKind`, never written out again (#395).
|
|
49
49
|
*
|
|
50
|
-
* ⚠️
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* ⚠️ Three decisions about this MENU, each excluded by name: `attachment` because an attachment is
|
|
51
|
+
* uploaded rather than created, `task` because a task lives on a board and the API refuses one
|
|
52
|
+
* filed under a folder, and `flow` added because a flow shares the tree without being a node
|
|
53
|
+
* (ADR-0004 §1). The LIST of node kinds is not a decision here, and a copy of it would go stale in
|
|
54
|
+
* the direction that already bit us — the parked kinds stood in such a union long after nothing
|
|
55
|
+
* could make one.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ `task` was NOT excluded until #661, and the wide form carried it in silence from #647 onward:
|
|
58
|
+
* a menu entry that could only ever answer with a refusal. It is a type error now.
|
|
54
59
|
*/
|
|
55
|
-
type NewKind = Exclude<NodeKind, "attachment"> | "flow";
|
|
60
|
+
type NewKind = Exclude<NodeKind, "attachment" | "task"> | "flow";
|
|
56
61
|
type Creating = { parentId: string | null; kind: NewKind };
|
|
57
62
|
|
|
58
63
|
// Attachments are inlined as base64, so the browser holds the file twice while it uploads.
|
|
@@ -852,7 +857,7 @@ function AddMenu(props: AddMenuProps) {
|
|
|
852
857
|
const { label, onSelect } = props;
|
|
853
858
|
const variant = props.variant ?? "icon";
|
|
854
859
|
const i18n = useI18n();
|
|
855
|
-
// ⚠️ Three groups, and the separators are the answer to what the plus promises:
|
|
860
|
+
// ⚠️ Three groups, and the separators are the answer to what the plus promises: five kinds are
|
|
856
861
|
// made here, an upload brings one thing in from the machine, an import brings a whole subtree
|
|
857
862
|
// (#346). The icons come from the one kind-icon map rather than being named again — a second
|
|
858
863
|
// `Folder` beside it is how the tree row and this menu start disagreeing.
|
|
@@ -860,6 +865,10 @@ function AddMenu(props: AddMenuProps) {
|
|
|
860
865
|
{ kind: "folder", labelKey: "tree.new.folder", Icon: kindIcons.folder },
|
|
861
866
|
{ kind: "document", labelKey: "tree.new.document", Icon: kindIcons.document },
|
|
862
867
|
{ kind: "table", labelKey: "tree.new.table", Icon: kindIcons.table },
|
|
868
|
+
// ⚠️ A board needs nothing else to be usable: an unconfigured one answers with three default
|
|
869
|
+
// columns (`DEFAULT_COLUMNS`, `packages/api/src/boards/boards.ts`), so unlike a table it has no
|
|
870
|
+
// second call here. A board with no columns would be a screen with nowhere to put a card.
|
|
871
|
+
{ kind: "board", labelKey: "tree.new.board", Icon: kindIcons.board },
|
|
863
872
|
{ kind: "flow", labelKey: "tree.new.flow", Icon: kindIcons.flow },
|
|
864
873
|
];
|
|
865
874
|
return (
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BoardColumn,
|
|
3
|
+
BoardTask,
|
|
4
|
+
BoardTaskCreateInput,
|
|
5
|
+
BoardTaskFilter,
|
|
6
|
+
BoardTaskUpdateInput,
|
|
7
|
+
BoardView,
|
|
8
|
+
} from "@anchrd/intel-contract/board";
|
|
9
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
12
|
+
import type { BoardHandle, ColumnWithCards } from "./board-data.types.ts";
|
|
13
|
+
|
|
14
|
+
export const boardKey = (boardId: string) => ["board", boardId] as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether this filter narrows anything at all.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ **`Object.keys(...).length` is the wrong question**, because clearing a control writes the key
|
|
20
|
+
* back with `undefined` rather than removing it — `{ status: undefined }` has one key and filters
|
|
21
|
+
* nothing. Read that as "filtering" and the table says "nothing matches this filter" about a board
|
|
22
|
+
* nobody is filtering, and a write pays a refetch it does not owe.
|
|
23
|
+
*/
|
|
24
|
+
export function isFiltering(filter: Partial<BoardTaskFilter>): boolean {
|
|
25
|
+
return Object.values(filter).some((value) => value !== undefined);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where a card lands when it is dropped between two neighbours.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ **The midpoint, and that is the whole reason `position` is a float.** With integers a drop
|
|
32
|
+
* would renumber every row below it, two people dropping at once would fight over rows neither
|
|
33
|
+
* touched, and a retry would move the card again. Here one card is written and nothing else is.
|
|
34
|
+
*
|
|
35
|
+
* The two edges are the cases a midpoint cannot express: dropped at the top there is nothing above,
|
|
36
|
+
* dropped at the bottom nothing below. `1` away from the neighbour keeps room for the next drop on
|
|
37
|
+
* that side without ever needing to renumber.
|
|
38
|
+
*/
|
|
39
|
+
export function positionBetween(above: number | undefined, below: number | undefined): number {
|
|
40
|
+
if (above === undefined && below === undefined) return 1;
|
|
41
|
+
if (above === undefined) return (below ?? 1) - 1;
|
|
42
|
+
if (below === undefined) return above + 1;
|
|
43
|
+
return (above + below) / 2;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The board as the views draw it: columns in their configured order, each carrying its own cards
|
|
48
|
+
* sorted by position.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ **A card whose status names no column is kept, in a column of its own at the end.** Dropping
|
|
51
|
+
* it would be the quiet failure: somebody renames a column, every card in it stops being drawn, and
|
|
52
|
+
* the screen looks like the cards were deleted. A visible stray column is ugly and true.
|
|
53
|
+
*/
|
|
54
|
+
export function columnsWithCards(view: BoardView): ColumnWithCards[] {
|
|
55
|
+
const byStatus = new Map<string, BoardTask[]>();
|
|
56
|
+
for (const task of view.tasks) {
|
|
57
|
+
const cards = byStatus.get(task.status);
|
|
58
|
+
if (cards === undefined) byStatus.set(task.status, [task]);
|
|
59
|
+
else cards.push(task);
|
|
60
|
+
}
|
|
61
|
+
const sorted = (cards: BoardTask[]) => [...cards].sort((a, b) => a.position - b.position);
|
|
62
|
+
const known: ColumnWithCards[] = view.columns.map((column) => ({
|
|
63
|
+
column,
|
|
64
|
+
cards: sorted(byStatus.get(column.id) ?? []),
|
|
65
|
+
}));
|
|
66
|
+
const orphaned = [...byStatus.keys()].filter(
|
|
67
|
+
(status) => !view.columns.some((column) => column.id === status),
|
|
68
|
+
);
|
|
69
|
+
const strays: ColumnWithCards[] = orphaned.map((status) => ({
|
|
70
|
+
column: { id: status, title: status, terminal: false } satisfies BoardColumn,
|
|
71
|
+
cards: sorted(byStatus.get(status) ?? []),
|
|
72
|
+
unknown: true,
|
|
73
|
+
}));
|
|
74
|
+
return [...known, ...strays];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Everything a board view needs, from one query and three mutations.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ **Every write answers with the whole board, and the answer is written straight into the
|
|
81
|
+
* cache.** The alternative — patch the cached board from the row that changed — is a second place
|
|
82
|
+
* that decides what a column contains, and the two drift the first time the server does something
|
|
83
|
+
* the client did not predict. A move, for instance, also gives the card a new position when none
|
|
84
|
+
* was sent.
|
|
85
|
+
*/
|
|
86
|
+
export function useBoard(boardId: string, filter: Partial<BoardTaskFilter> = {}): BoardHandle {
|
|
87
|
+
const { data } = useIntelRouterContext();
|
|
88
|
+
const client = useQueryClient();
|
|
89
|
+
const key = boardKey(boardId);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* ⚠️ **The filter belongs in the key, and `includeArchived` is only its most obvious member.**
|
|
93
|
+
* Without it every filtered view of a board shares one cache entry: switching a filter shows the
|
|
94
|
+
* previous selection's cards until the fetch returns, which reads as cards appearing and
|
|
95
|
+
* vanishing on their own.
|
|
96
|
+
*
|
|
97
|
+
* ⚠️ **The filter travels; it is not applied here.** A view that fetched everything and hid rows
|
|
98
|
+
* in the browser looks identical and is a different thing — `board-table.unit.tsx` therefore
|
|
99
|
+
* asserts the ARGUMENT of the second request, not just that a request happened.
|
|
100
|
+
*/
|
|
101
|
+
const scoped = [...key, filter] as const;
|
|
102
|
+
const unfiltered = [...key, {}] as const;
|
|
103
|
+
const query = useQuery({
|
|
104
|
+
queryKey: scoped,
|
|
105
|
+
// `includeArchived` is spelled out because the provider takes the contract's OUTPUT type,
|
|
106
|
+
// where the default has already been applied and the field is therefore required.
|
|
107
|
+
queryFn: async () => await data.getBoard({ includeArchived: false, boardId, ...filter }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Where a write's answer goes.
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ **A write always answers with the board UNFILTERED, whatever this handle is filtered by.**
|
|
114
|
+
* All three write paths ask for the view with a fixed `{ boardId, includeArchived: false }`
|
|
115
|
+
* (`packages/api/src/boards/boards.ts`), and none of the three inputs even carries a filter field.
|
|
116
|
+
* So the answer belongs in the unfiltered entry — and settling it into the filtered one would put
|
|
117
|
+
* every card into a view that asked for some of them, with no error, because the write succeeded.
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ **The filtered entry is therefore invalidated rather than patched.** This answer cannot say
|
|
120
|
+
* what the filter now matches: a card that was moved out of it has to disappear, one moved into it
|
|
121
|
+
* has to appear, and only the server knows which. One extra request per write under a filter, and
|
|
122
|
+
* an unfiltered board — the ordinary case — still pays none.
|
|
123
|
+
*/
|
|
124
|
+
const settle = (view: BoardView) => {
|
|
125
|
+
client.setQueryData(unfiltered, view);
|
|
126
|
+
if (isFiltering(filter)) void client.invalidateQueries({ queryKey: scoped });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const createTask = useMutation({
|
|
130
|
+
mutationFn: async (input: Omit<BoardTaskCreateInput, "boardId">) =>
|
|
131
|
+
await data.createBoardTask({ ...input, boardId }),
|
|
132
|
+
onSuccess: settle,
|
|
133
|
+
});
|
|
134
|
+
const updateTask = useMutation({
|
|
135
|
+
mutationFn: async (input: BoardTaskUpdateInput) => await data.updateBoardTask(boardId, input),
|
|
136
|
+
onSuccess: settle,
|
|
137
|
+
});
|
|
138
|
+
const setColumns = useMutation({
|
|
139
|
+
mutationFn: async (input: { columns: BoardColumn[]; idempotencyKey: string }) =>
|
|
140
|
+
await data.updateBoard({ boardId, ...input }),
|
|
141
|
+
onSuccess: settle,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const columns = useMemo(
|
|
145
|
+
() => (query.data === undefined ? [] : columnsWithCards(query.data)),
|
|
146
|
+
[query.data],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
boardId,
|
|
151
|
+
title: query.data?.title ?? "",
|
|
152
|
+
columns,
|
|
153
|
+
isPending: query.isPending,
|
|
154
|
+
isError: query.isError,
|
|
155
|
+
// ⚠️ One flag for all three writes. A screen that disabled only the button it just pressed
|
|
156
|
+
// would let a second drag start while the first is in flight, and the second answer would
|
|
157
|
+
// overwrite the first — with no error, because both succeeded.
|
|
158
|
+
isWriting: createTask.isPending || updateTask.isPending || setColumns.isPending,
|
|
159
|
+
createTask: async (input) => {
|
|
160
|
+
await createTask.mutateAsync(input);
|
|
161
|
+
},
|
|
162
|
+
updateTask: async (input) => {
|
|
163
|
+
await updateTask.mutateAsync(input);
|
|
164
|
+
},
|
|
165
|
+
setColumns: async (input) => {
|
|
166
|
+
await setColumns.mutateAsync(input);
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BoardColumn,
|
|
3
|
+
BoardTask,
|
|
4
|
+
BoardTaskCreateInput,
|
|
5
|
+
BoardTaskUpdateInput,
|
|
6
|
+
} from "@anchrd/intel-contract/board";
|
|
7
|
+
|
|
8
|
+
// One column and the cards in it, in the order they are drawn.
|
|
9
|
+
export interface ColumnWithCards {
|
|
10
|
+
column: BoardColumn;
|
|
11
|
+
cards: BoardTask[];
|
|
12
|
+
// ⚠️ Set only on a column the board does not configure — cards whose status names nothing. It is
|
|
13
|
+
// drawn differently rather than hidden: a card nobody can see is a card somebody will look for.
|
|
14
|
+
unknown?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BoardHandle {
|
|
18
|
+
boardId: string;
|
|
19
|
+
title: string;
|
|
20
|
+
columns: ColumnWithCards[];
|
|
21
|
+
isPending: boolean;
|
|
22
|
+
isError: boolean;
|
|
23
|
+
isWriting: boolean;
|
|
24
|
+
// `boardId` is supplied by the handle, so a view cannot file a card on the wrong board by
|
|
25
|
+
// forgetting a field.
|
|
26
|
+
createTask(input: Omit<BoardTaskCreateInput, "boardId">): Promise<void>;
|
|
27
|
+
updateTask(input: BoardTaskUpdateInput): Promise<void>;
|
|
28
|
+
setColumns(input: { columns: BoardColumn[]; idempotencyKey: string }): Promise<void>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
+
import { positionBetween } from "@/board/board-data/board-data.ts";
|
|
3
|
+
import type { ColumnWithCards } from "@/board/board-data/board-data.types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a dragged card was let go.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **`index` counts into the column AS RENDERED — including the dragged card itself when it
|
|
9
|
+
* started there.** That is what a drop handler can actually measure: the card stays in the list
|
|
10
|
+
* while the gesture runs, and the browser reports the gap it hovers over. Reading it as an index
|
|
11
|
+
* into the list *without* the card is off by one for every downward move inside a column, and the
|
|
12
|
+
* symptom is a card landing one place short of where it was let go — which looks like a sloppy
|
|
13
|
+
* animation rather than a bug.
|
|
14
|
+
*/
|
|
15
|
+
export interface Drop {
|
|
16
|
+
status: string;
|
|
17
|
+
index: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a drop means as a write — the whole of the drag logic, kept out of the component so it can be
|
|
22
|
+
* proven without a browser.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ **`null` means "changed nothing", and the caller must not write.** A card let go where it
|
|
25
|
+
* already sits is the most common drop there is: somebody picks one up, thinks again, and puts it
|
|
26
|
+
* back. Writing anyway costs a round trip, a re-render, and — because the position would be
|
|
27
|
+
* recomputed — a card that visibly jumps for no reason.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ **The card itself is removed from the neighbour list first.** Without that, dragging a card
|
|
30
|
+
* one place down within its own column measures against its OWN old position: the midpoint lands
|
|
31
|
+
* between the card and its neighbour, which is where it already was. It would look like the drag
|
|
32
|
+
* did nothing, which is the same symptom as a broken drop handler and reads as one.
|
|
33
|
+
*/
|
|
34
|
+
export function dropToWrite(
|
|
35
|
+
card: BoardTask,
|
|
36
|
+
columns: ColumnWithCards[],
|
|
37
|
+
drop: Drop,
|
|
38
|
+
): { status: string; position: number } | null {
|
|
39
|
+
const target = columns.find((entry) => entry.column.id === drop.status);
|
|
40
|
+
if (target === undefined) return null;
|
|
41
|
+
|
|
42
|
+
const others = target.cards.filter((entry) => entry.id !== card.id);
|
|
43
|
+
const wasAt = target.cards.findIndex((entry) => entry.id === card.id);
|
|
44
|
+
|
|
45
|
+
// ⚠️ The card is counted out of its own gap. Dropped BELOW where it started, every card it passed
|
|
46
|
+
// has shifted up by one, so the index into the remaining cards is one lower. Above its start
|
|
47
|
+
// nothing moved and the index carries over unchanged.
|
|
48
|
+
const wanted = wasAt >= 0 && drop.index > wasAt ? drop.index - 1 : drop.index;
|
|
49
|
+
const index = Math.max(0, Math.min(wanted, others.length));
|
|
50
|
+
|
|
51
|
+
// Let go in its own gap: nothing moved, and writing anyway would recompute the position and make
|
|
52
|
+
// the card jump for no reason.
|
|
53
|
+
if (wasAt >= 0 && index === wasAt) return null;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
status: drop.status,
|
|
57
|
+
position: positionBetween(others[index - 1]?.position, others[index]?.position),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The card a keyboard move should land on, given the one it starts from.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ Keyboard reachability is part of the first version here, not polish — dragging is a mouse
|
|
65
|
+
* gesture and a board that can only be operated by dragging is a board half the people cannot use.
|
|
66
|
+
* Left and right move between columns and keep the row; up and down move within one.
|
|
67
|
+
*/
|
|
68
|
+
export function neighbourDrop(
|
|
69
|
+
card: BoardTask,
|
|
70
|
+
columns: ColumnWithCards[],
|
|
71
|
+
direction: "left" | "right" | "up" | "down",
|
|
72
|
+
): Drop | null {
|
|
73
|
+
const columnIndex = columns.findIndex((entry) => entry.column.id === card.status);
|
|
74
|
+
const column = columns[columnIndex];
|
|
75
|
+
if (column === undefined) return null;
|
|
76
|
+
const row = column.cards.findIndex((entry) => entry.id === card.id);
|
|
77
|
+
|
|
78
|
+
if (direction === "left" || direction === "right") {
|
|
79
|
+
const next = columns[columnIndex + (direction === "left" ? -1 : 1)];
|
|
80
|
+
// ⚠️ No wrapping. A card that jumps from the last column to the first because somebody pressed
|
|
81
|
+
// the arrow once too often is a move nobody asked for, and the one they did ask for is lost.
|
|
82
|
+
if (next === undefined) return null;
|
|
83
|
+
return { status: next.column.id, index: Math.min(row, next.cards.length) };
|
|
84
|
+
}
|
|
85
|
+
const landing = row + (direction === "up" ? -1 : 1);
|
|
86
|
+
if (landing < 0 || landing >= column.cards.length) return null;
|
|
87
|
+
// ⚠️ Down needs one more than the row it lands on. `index` is a GAP in the list AS RENDERED, and
|
|
88
|
+
// moving down one place means ending up BEHIND the neighbour — that is the gap after it. Naming
|
|
89
|
+
// the neighbour's own gap names the gap the card already occupies, and `dropToWrite` then
|
|
90
|
+
// correctly answers "nothing changed" and swallows the move. Upwards the two coincide, which is
|
|
91
|
+
// why this reads as an asymmetry and is not one.
|
|
92
|
+
return { status: column.column.id, index: direction === "down" ? landing + 1 : landing };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Which gap a drop ON a card means — the half of the card the pointer is over.
|
|
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.
|
|
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.
|
|
105
|
+
*/
|
|
106
|
+
export function dropIndexOnCard(row: number, pointerY: number, box: DOMRect): number {
|
|
107
|
+
return pointerY < box.top + box.height / 2 ? row : row + 1;
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|