@anchrd/intel-ui 0.16.1 → 0.18.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 +10 -2
- package/src/agent/agent-calendar/agent-calendar.tsx +27 -2
- package/src/agent/agent-profile/agent-profile.tsx +20 -2
- package/src/agent/agent.tsx +27 -2
- package/src/board/board-calendar/board-calendar.tsx +89 -0
- package/src/board/board-card/board-card.tsx +106 -0
- package/src/board/board-data/board-data.ts +241 -0
- package/src/board/board-data/board-data.types.ts +63 -0
- package/src/board/board-detail/board-detail.tsx +629 -0
- package/src/board/board-gantt/board-gantt.ts +545 -0
- package/src/board/board-gantt/board-gantt.tsx +286 -0
- package/src/board/board-graph/board-graph.ts +174 -0
- package/src/board/board-graph/board-graph.tsx +168 -0
- package/src/board/board-items/board-items.ts +183 -0
- package/src/board/board-kanban/board-kanban.ts +137 -0
- package/src/board/board-kanban/board-kanban.tsx +257 -0
- package/src/board/board-status/board-status.ts +59 -0
- package/src/board/board-statuses/board-statuses.ts +63 -0
- package/src/board/board-statuses/board-statuses.tsx +228 -0
- package/src/board/board-table/board-table.ts +33 -0
- package/src/board/board-table/board-table.tsx +413 -0
- package/src/board/board-views/board-views.tsx +68 -0
- package/src/board/board-views/board-views.types.ts +29 -0
- package/src/board/board.tsx +251 -0
- package/src/components/ui/dropdown-menu.tsx +25 -0
- package/src/components/ui/item-calendar.tsx +181 -0
- package/src/components/ui/item-gantt.tsx +463 -0
- package/src/components/ui/kanban.tsx +282 -0
- package/src/components/ui/switch.tsx +25 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
- package/src/i18n/de.json +90 -1
- package/src/i18n/en.json +90 -1
- package/src/i18n/es.json +90 -1
- package/src/node-table/node-table.tsx +3 -2
- package/src/nodes/nodes.tsx +16 -0
- package/src/resource-menu/resource-menu.tsx +26 -6
- package/src/styles.css +33 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ArchivedBoardStatusId, type BoardStatus } from "@anchrd/intel-contract";
|
|
2
|
+
|
|
3
|
+
// One row of the dialog. `terminal` travels with it because it is written in the same call as the
|
|
4
|
+
// label and the order — `board_configure` takes the list whole (#285).
|
|
5
|
+
export interface BoardStatusDraft {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
terminal: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A status id out of the label somebody typed.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ The id is what every task in the column carries, so it is minted ONCE and never follows a
|
|
15
|
+
* rename — renaming changes the label and no task has to be rewritten (#285). This function is
|
|
16
|
+
* therefore only ever called for a NEW column.
|
|
17
|
+
*
|
|
18
|
+
* The shape is the contract's: lower case, digits and underscores, starting on an alphanumeric,
|
|
19
|
+
* forty characters at most. A label that leaves nothing usable — one written in a script that has
|
|
20
|
+
* no ASCII form — falls back to a generated id rather than being refused, because a column named
|
|
21
|
+
* in Japanese is a column somebody meant to make.
|
|
22
|
+
*/
|
|
23
|
+
export function statusIdFrom(label: string, taken: ReadonlySet<string>): string {
|
|
24
|
+
const base = label
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.normalize("NFKD")
|
|
27
|
+
// Combining marks removed, so "Prüfung" becomes "prufung" rather than losing the letter.
|
|
28
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
29
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
30
|
+
.replace(/^_+|_+$/g, "")
|
|
31
|
+
.slice(0, 40)
|
|
32
|
+
.replace(/^[^a-z0-9]+/, "");
|
|
33
|
+
const seed = base === "" ? "status" : base;
|
|
34
|
+
if (!taken.has(seed) && seed !== ArchivedBoardStatusId) return seed;
|
|
35
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
36
|
+
const candidate = `${seed.slice(0, 37)}_${suffix}`;
|
|
37
|
+
if (!taken.has(candidate) && candidate !== ArchivedBoardStatusId) return candidate;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The list as `board_configure` takes it: whole, in the order it should be drawn.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ `archived` is put back at the end if the editor lost it. The contract refuses a list without
|
|
45
|
+
* it, and a refusal here would be a dialog that cannot be saved for a reason the reader never
|
|
46
|
+
* caused — the shelf is not a column anybody is editing.
|
|
47
|
+
*/
|
|
48
|
+
export function configurableStatuses(statuses: BoardStatus[]): BoardStatusDraft[] {
|
|
49
|
+
const working = statuses.filter((status) => status.id !== ArchivedBoardStatusId);
|
|
50
|
+
const shelf = statuses.find((status) => status.id === ArchivedBoardStatusId);
|
|
51
|
+
return [
|
|
52
|
+
...working.map((status) => ({
|
|
53
|
+
id: status.id,
|
|
54
|
+
label: status.label,
|
|
55
|
+
terminal: status.terminal,
|
|
56
|
+
})),
|
|
57
|
+
// ⚠️ Always `true` for the shelf, whatever a stored board says. The contract refuses an explicit
|
|
58
|
+
// `false` for it (anchrd/intel#311), so sending anything else would be a dialog that cannot be
|
|
59
|
+
// saved — and a shelf that did not mean finished would hold every archived task open as a
|
|
60
|
+
// blocker forever.
|
|
61
|
+
{ id: ArchivedBoardStatusId, label: shelf?.label ?? "Archived", terminal: true },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { ArchivedBoardStatusId } from "@anchrd/intel-contract";
|
|
2
|
+
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "@/components/ui/dialog";
|
|
12
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
|
+
import { configurableStatuses, statusIdFrom } from "./board-statuses.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The status list, edited (anchrd/intel#286, `board_configure`).
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ The whole list is written at once and never patched — adding, renaming and reordering are one
|
|
19
|
+
* call, because the order IS the list's order (#285). So the dialog holds a draft and sends it on
|
|
20
|
+
* save; a control that wrote per keystroke would write a version per letter.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ `archived` can be renamed and cannot be removed or moved. The contract refuses a list without
|
|
23
|
+
* it, and a shelf that could be dragged into the middle of the working columns would be a shelf
|
|
24
|
+
* pretending to be a stage.
|
|
25
|
+
*/
|
|
26
|
+
export function BoardStatuses({ board, onClose }: { board: BoardHandle; onClose(): void }) {
|
|
27
|
+
const i18n = useI18n();
|
|
28
|
+
// ⚠️ Seeded once per OPENING, which is why the caller mounts this only while it is open
|
|
29
|
+
// (`board.tsx`) instead of handing it an `open` flag. Radix never reports `onOpenChange(true)`
|
|
30
|
+
// for a dialog whose openness is decided outside it, so a re-seed hung off that callback would be
|
|
31
|
+
// dead code — and the draft would then be whatever the list looked like the first time anybody
|
|
32
|
+
// opened this board. Saving it would write that stale list WHOLE and revert a column somebody
|
|
33
|
+
// else had added in between.
|
|
34
|
+
const [draft, setDraft] = useState(() => configurableStatuses(board.statuses));
|
|
35
|
+
const [added, setAdded] = useState("");
|
|
36
|
+
// ⚠️ `board.configure` lives in `useBoard` and outlives this dialog, so its `isError` is still set
|
|
37
|
+
// the next time the dialog is opened — a refusal from ten minutes ago greeting a list nobody has
|
|
38
|
+
// tried to save yet. A local flag rather than `configure.reset()`, because resetting shared state
|
|
39
|
+
// from a mount is a side effect on something this dialog does not own.
|
|
40
|
+
const [attempted, setAttempted] = useState(false);
|
|
41
|
+
|
|
42
|
+
const working = draft.filter((status) => status.id !== ArchivedBoardStatusId);
|
|
43
|
+
const shelf = draft.find((status) => status.id === ArchivedBoardStatusId);
|
|
44
|
+
|
|
45
|
+
const move = (index: number, delta: number) => {
|
|
46
|
+
const target = index + delta;
|
|
47
|
+
if (target < 0 || target >= working.length) return;
|
|
48
|
+
const next = [...working];
|
|
49
|
+
const [item] = next.splice(index, 1);
|
|
50
|
+
if (item) next.splice(target, 0, item);
|
|
51
|
+
setDraft([...next, ...(shelf ? [shelf] : [])]);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
|
56
|
+
<DialogContent>
|
|
57
|
+
<DialogHeader>
|
|
58
|
+
<DialogTitle>{i18n.t("board.statuses")}</DialogTitle>
|
|
59
|
+
</DialogHeader>
|
|
60
|
+
<ul className="flex flex-col gap-2">
|
|
61
|
+
{working.map((status, index) => (
|
|
62
|
+
<li key={status.id} className="flex items-center gap-2">
|
|
63
|
+
<input
|
|
64
|
+
value={status.label}
|
|
65
|
+
aria-label={i18n.t("board.statusLabel")}
|
|
66
|
+
onChange={(event) => {
|
|
67
|
+
const label = event.currentTarget.value;
|
|
68
|
+
setDraft((current) =>
|
|
69
|
+
current.map((entry) => (entry.id === status.id ? { ...entry, label } : entry)),
|
|
70
|
+
);
|
|
71
|
+
}}
|
|
72
|
+
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
73
|
+
/>
|
|
74
|
+
{/* ⚠️ Which columns mean finished is a property of the column, not its position
|
|
75
|
+
(anchrd/intel#311) — so it is set here, per column, and several may carry it. It is
|
|
76
|
+
what decides whether a task waiting on this one is still blocked. */}
|
|
77
|
+
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
78
|
+
<input
|
|
79
|
+
type="checkbox"
|
|
80
|
+
checked={status.terminal}
|
|
81
|
+
onChange={(event) => {
|
|
82
|
+
const terminal = event.currentTarget.checked;
|
|
83
|
+
setDraft((current) =>
|
|
84
|
+
current.map((entry) =>
|
|
85
|
+
entry.id === status.id ? { ...entry, terminal } : entry,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}}
|
|
89
|
+
className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
90
|
+
/>
|
|
91
|
+
{i18n.t("board.statusTerminal")}
|
|
92
|
+
</label>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
aria-label={i18n.t("board.statusUp")}
|
|
96
|
+
disabled={index === 0}
|
|
97
|
+
onClick={() => move(index, -1)}
|
|
98
|
+
className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
99
|
+
>
|
|
100
|
+
<ChevronUp aria-hidden="true" className="size-4" />
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
aria-label={i18n.t("board.statusDown")}
|
|
105
|
+
disabled={index === working.length - 1}
|
|
106
|
+
onClick={() => move(index, 1)}
|
|
107
|
+
className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
108
|
+
>
|
|
109
|
+
<ChevronDown aria-hidden="true" className="size-4" />
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
aria-label={i18n.t("board.statusRemove", { label: status.label })}
|
|
114
|
+
// A column with tasks still in it cannot be taken away: the server refuses a task
|
|
115
|
+
// in a status the board does not have, so removing it would make the board
|
|
116
|
+
// unwritable rather than tidy.
|
|
117
|
+
disabled={board.tasks.some((task) => task.status === status.id)}
|
|
118
|
+
onClick={() =>
|
|
119
|
+
setDraft((current) => current.filter((entry) => entry.id !== status.id))
|
|
120
|
+
}
|
|
121
|
+
className="grid size-8 place-items-center rounded-md border text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
122
|
+
>
|
|
123
|
+
<X aria-hidden="true" className="size-4" />
|
|
124
|
+
</button>
|
|
125
|
+
</li>
|
|
126
|
+
))}
|
|
127
|
+
{shelf ? (
|
|
128
|
+
<li className="flex items-center gap-2">
|
|
129
|
+
<input
|
|
130
|
+
value={shelf.label}
|
|
131
|
+
aria-label={i18n.t("board.statusLabel")}
|
|
132
|
+
onChange={(event) => {
|
|
133
|
+
const label = event.currentTarget.value;
|
|
134
|
+
setDraft((current) =>
|
|
135
|
+
current.map((entry) =>
|
|
136
|
+
entry.id === ArchivedBoardStatusId ? { ...entry, label } : entry,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}}
|
|
140
|
+
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
141
|
+
/>
|
|
142
|
+
<span className="text-xs text-muted-foreground">{i18n.t("board.shelfFixed")}</span>
|
|
143
|
+
{/* The shelf's flag is shown and cannot be turned off: the contract refuses an explicit
|
|
144
|
+
`false` for it, and a shelf that did not mean finished would hold every archived
|
|
145
|
+
task open as a blocker (anchrd/intel#311). */}
|
|
146
|
+
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
147
|
+
<input
|
|
148
|
+
type="checkbox"
|
|
149
|
+
checked
|
|
150
|
+
disabled
|
|
151
|
+
aria-label={i18n.t("board.statusTerminal")}
|
|
152
|
+
className="size-4 rounded border"
|
|
153
|
+
/>
|
|
154
|
+
{i18n.t("board.statusTerminal")}
|
|
155
|
+
</label>
|
|
156
|
+
</li>
|
|
157
|
+
) : null}
|
|
158
|
+
</ul>
|
|
159
|
+
<form
|
|
160
|
+
className="flex gap-2"
|
|
161
|
+
onSubmit={(event) => {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
const label = added.trim();
|
|
164
|
+
if (label === "") return;
|
|
165
|
+
setDraft((current) => {
|
|
166
|
+
const taken = new Set(current.map((entry) => entry.id));
|
|
167
|
+
const shelfEntry = current.find((entry) => entry.id === ArchivedBoardStatusId);
|
|
168
|
+
return [
|
|
169
|
+
...current.filter((entry) => entry.id !== ArchivedBoardStatusId),
|
|
170
|
+
// A new column is work, not the end of it. Meaning "finished" is something somebody
|
|
171
|
+
// says on purpose, with the checkbox beside it.
|
|
172
|
+
{ id: statusIdFrom(label, taken), label, terminal: false },
|
|
173
|
+
...(shelfEntry ? [shelfEntry] : []),
|
|
174
|
+
];
|
|
175
|
+
});
|
|
176
|
+
setAdded("");
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<input
|
|
180
|
+
value={added}
|
|
181
|
+
onChange={(event) => setAdded(event.currentTarget.value)}
|
|
182
|
+
placeholder={i18n.t("board.addStatus")}
|
|
183
|
+
aria-label={i18n.t("board.addStatus")}
|
|
184
|
+
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
185
|
+
/>
|
|
186
|
+
<button
|
|
187
|
+
type="submit"
|
|
188
|
+
className="h-8 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
189
|
+
>
|
|
190
|
+
{i18n.t("common.create")}
|
|
191
|
+
</button>
|
|
192
|
+
</form>
|
|
193
|
+
{attempted && board.configure.isError ? (
|
|
194
|
+
<p role="alert" className="text-sm text-destructive">
|
|
195
|
+
{i18n.t("node.operationFailed")}
|
|
196
|
+
</p>
|
|
197
|
+
) : null}
|
|
198
|
+
<DialogFooter>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={onClose}
|
|
202
|
+
className="h-9 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
203
|
+
>
|
|
204
|
+
{i18n.t("common.close")}
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
disabled={board.configure.isPending || draft.some((entry) => entry.label.trim() === "")}
|
|
209
|
+
onClick={() => {
|
|
210
|
+
setAttempted(true);
|
|
211
|
+
board.configure.mutate(
|
|
212
|
+
draft.map((entry) => ({
|
|
213
|
+
id: entry.id,
|
|
214
|
+
label: entry.label.trim(),
|
|
215
|
+
terminal: entry.terminal,
|
|
216
|
+
})),
|
|
217
|
+
{ onSuccess: onClose },
|
|
218
|
+
);
|
|
219
|
+
}}
|
|
220
|
+
className="h-9 rounded-md bg-primary px-3 text-sm text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
221
|
+
>
|
|
222
|
+
{board.configure.isPending ? i18n.t("common.saving") : i18n.t("common.save")}
|
|
223
|
+
</button>
|
|
224
|
+
</DialogFooter>
|
|
225
|
+
</DialogContent>
|
|
226
|
+
</Dialog>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract";
|
|
2
|
+
import { rootedParents } from "@/board/board-items/board-items.ts";
|
|
3
|
+
|
|
4
|
+
// A task with the tasks under it, which is the shape `getSubRows` reads. `subRows` is absent rather
|
|
5
|
+
// than empty for a leaf: TanStack treats an empty array as "expandable, with nothing in it" and
|
|
6
|
+
// would draw a chevron that opens onto nothing.
|
|
7
|
+
export interface BoardRow extends BoardTask {
|
|
8
|
+
subRows?: BoardRow[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The board as the tree the table draws (anchrd/intel#286).
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Which task a row hangs under is `rootedParents`, shared with Gantt (anchrd/intel#293) rather
|
|
15
|
+
* than kept here. That walk carries two subtleties — an orphan becomes a root instead of vanishing
|
|
16
|
+
* with its hidden parent, and a cycle is broken at the task that closes it — and two copies of those
|
|
17
|
+
* are two copies that drift, which would show as a task that is a root in one view and a child in
|
|
18
|
+
* the other.
|
|
19
|
+
*/
|
|
20
|
+
export function boardRows(tasks: BoardTask[]): BoardRow[] {
|
|
21
|
+
const parents = rootedParents(tasks);
|
|
22
|
+
const rows = new Map<string, BoardRow>(tasks.map((task) => [task.id, { ...task }]));
|
|
23
|
+
const roots: BoardRow[] = [];
|
|
24
|
+
for (const task of tasks) {
|
|
25
|
+
const row = rows.get(task.id);
|
|
26
|
+
if (!row) continue;
|
|
27
|
+
const parent = parents.get(task.id) ?? null;
|
|
28
|
+
const target = parent === null ? undefined : rows.get(parent);
|
|
29
|
+
if (target === undefined) roots.push(row);
|
|
30
|
+
else target.subRows = [...(target.subRows ?? []), row];
|
|
31
|
+
}
|
|
32
|
+
return roots;
|
|
33
|
+
}
|