@anchrd/intel-ui 0.40.0 → 0.42.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 +8 -1
- package/src/app/app-tree/app-tree.tsx +21 -0
- package/src/attachment-viewer/attachment-viewer.tsx +305 -0
- package/src/board/board-assignee/board-assignee.tsx +45 -0
- package/src/board/board-chip/board-chip.tsx +31 -0
- package/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/src/board/board-crumbs/board-crumbs.tsx +108 -0
- package/src/board/board-kanban/board-kanban.tsx +43 -8
- package/src/board/board-open-task/board-open-task.ts +32 -0
- package/src/board/board-panel/board-panel.tsx +17 -37
- package/src/board/board-status/board-status.tsx +60 -0
- package/src/board/board-table/board-table.tsx +207 -101
- package/src/board/board-task/board-task.tsx +144 -195
- package/src/board/board-title-row/board-title-row.tsx +68 -0
- package/src/file-preview/file-preview-view.tsx +342 -0
- package/src/file-preview/file-preview.tsx +28 -0
- package/src/file-preview/pdf-file-preview.tsx +5 -0
- package/src/file-preview/presentation-file-preview.tsx +21 -0
- package/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/src/file-preview/word-file-preview.tsx +5 -0
- package/src/i18n/de.json +62 -6
- package/src/i18n/en.json +62 -6
- package/src/i18n/es.json +62 -6
- package/src/nodes/nodes.tsx +26 -52
- package/src/resource-menu/resource-menu.tsx +18 -12
- package/src/title-row/title-row.tsx +33 -6
- package/vite.config.ts +111 -3
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
createGroupedRowModel,
|
|
9
9
|
createSortedRowModel,
|
|
10
10
|
type ExpandedState,
|
|
11
|
-
flexRender,
|
|
12
11
|
type GroupingState,
|
|
13
12
|
type Row as LibRow,
|
|
14
13
|
rowExpandingFeature,
|
|
@@ -19,11 +18,14 @@ import {
|
|
|
19
18
|
tableFeatures,
|
|
20
19
|
useTable,
|
|
21
20
|
} from "@tanstack/react-table";
|
|
22
|
-
import { ChevronDown, ChevronRight, Columns3, Filter, LayoutList } from "lucide-react";
|
|
21
|
+
import { ArrowUpDown, ChevronDown, ChevronRight, Columns3, Filter, LayoutList } from "lucide-react";
|
|
23
22
|
import { useMemo, useState } from "react";
|
|
24
23
|
import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
|
|
24
|
+
import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
|
|
25
|
+
import { BoardChip } from "@/board/board-chip/board-chip.tsx";
|
|
25
26
|
import { isFiltering } from "@/board/board-data/board-data.ts";
|
|
26
27
|
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
28
|
+
import { StatusChip } from "@/board/board-status/board-status.tsx";
|
|
27
29
|
import {
|
|
28
30
|
DropdownMenu,
|
|
29
31
|
DropdownMenuCheckboxItem,
|
|
@@ -112,45 +114,13 @@ export function BoardTable({
|
|
|
112
114
|
helper.accessor((row) => row.task.title, {
|
|
113
115
|
id: "title",
|
|
114
116
|
header: i18n.t("board.column.title"),
|
|
117
|
+
// ⚠️ Not hideable. It IS the row: an entry in the chooser that changes nothing when
|
|
118
|
+
// pressed is a control that lies about what it does.
|
|
119
|
+
enableHiding: false,
|
|
115
120
|
sortFn: "alphanumeric",
|
|
116
121
|
// Grouping by the title would make one group per row, which is the table again with an
|
|
117
122
|
// extra line above every entry.
|
|
118
123
|
enableGrouping: false,
|
|
119
|
-
cell: ({ row, getValue }) => (
|
|
120
|
-
<span
|
|
121
|
-
className="flex items-center gap-1"
|
|
122
|
-
// The depth is the indent, and it is the only thing that says what hangs under what.
|
|
123
|
-
style={{ paddingLeft: `${row.depth * 20}px` }}
|
|
124
|
-
>
|
|
125
|
-
{row.getCanExpand() ? (
|
|
126
|
-
<button
|
|
127
|
-
type="button"
|
|
128
|
-
aria-label={
|
|
129
|
-
row.getIsExpanded()
|
|
130
|
-
? i18n.t("board.table.collapse", { row: String(getValue()) })
|
|
131
|
-
: i18n.t("board.table.expand", { row: String(getValue()) })
|
|
132
|
-
}
|
|
133
|
-
onClick={row.getToggleExpandedHandler()}
|
|
134
|
-
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
135
|
-
>
|
|
136
|
-
{row.getIsExpanded() ? (
|
|
137
|
-
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
138
|
-
) : (
|
|
139
|
-
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
140
|
-
)}
|
|
141
|
-
</button>
|
|
142
|
-
) : (
|
|
143
|
-
<span className="size-4" />
|
|
144
|
-
)}
|
|
145
|
-
<button
|
|
146
|
-
type="button"
|
|
147
|
-
onClick={() => onOpen(row.original.task.id)}
|
|
148
|
-
className="truncate rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
149
|
-
>
|
|
150
|
-
{String(getValue())}
|
|
151
|
-
</button>
|
|
152
|
-
</span>
|
|
153
|
-
),
|
|
154
124
|
}),
|
|
155
125
|
helper.accessor((row) => row.columnTitle, {
|
|
156
126
|
id: "status",
|
|
@@ -164,7 +134,14 @@ export function BoardTable({
|
|
|
164
134
|
sortUndefined: "last",
|
|
165
135
|
// ⚠️ Grouped by the RAW id, drawn as a name. Grouping by what is drawn would put everybody
|
|
166
136
|
// this browser cannot name into one group called "a user account".
|
|
167
|
-
|
|
137
|
+
}),
|
|
138
|
+
helper.accessor((row) => orNothing(row.task.startDate), {
|
|
139
|
+
id: "start",
|
|
140
|
+
header: i18n.t("board.column.start"),
|
|
141
|
+
// An ISO date sorts correctly as a string, so `basic` is the whole of the comparison.
|
|
142
|
+
sortFn: "basic",
|
|
143
|
+
sortUndefined: "last",
|
|
144
|
+
enableGrouping: false,
|
|
168
145
|
}),
|
|
169
146
|
helper.accessor((row) => orNothing(row.task.dueDate), {
|
|
170
147
|
id: "due",
|
|
@@ -173,9 +150,6 @@ export function BoardTable({
|
|
|
173
150
|
sortFn: "basic",
|
|
174
151
|
sortUndefined: "last",
|
|
175
152
|
enableGrouping: false,
|
|
176
|
-
cell: ({ getValue }) => (
|
|
177
|
-
<span className="tabular-nums">{getValue()?.slice(0, 10) ?? ""}</span>
|
|
178
|
-
),
|
|
179
153
|
}),
|
|
180
154
|
helper.accessor((row) => orNothing(row.blocking), {
|
|
181
155
|
id: "dependsOn",
|
|
@@ -184,7 +158,6 @@ export function BoardTable({
|
|
|
184
158
|
sortUndefined: "last",
|
|
185
159
|
// Grouping by what a card waits for is a real question: everything blocked by the same
|
|
186
160
|
// card, in one place.
|
|
187
|
-
cell: ({ getValue }) => getValue() ?? "",
|
|
188
161
|
}),
|
|
189
162
|
helper.accessor((row) => row.task.labels.join(", "), {
|
|
190
163
|
id: "labels",
|
|
@@ -197,7 +170,7 @@ export function BoardTable({
|
|
|
197
170
|
enableGrouping: false,
|
|
198
171
|
}),
|
|
199
172
|
]),
|
|
200
|
-
[i18n
|
|
173
|
+
[i18n],
|
|
201
174
|
);
|
|
202
175
|
|
|
203
176
|
const table = useTable({
|
|
@@ -236,11 +209,14 @@ export function BoardTable({
|
|
|
236
209
|
|
|
237
210
|
const grouped = grouping[0] ?? "none";
|
|
238
211
|
const groupable = table.getAllLeafColumns().filter((column) => column.getCanGroup());
|
|
212
|
+
const sortable = table.getAllLeafColumns().filter((column) => column.getCanSort());
|
|
239
213
|
const configured: BoardColumn[] = board.columns
|
|
240
214
|
.filter((entry) => entry.unknown !== true)
|
|
241
215
|
.map((entry) => entry.column);
|
|
242
216
|
const rows = table.getRowModel().rows;
|
|
243
|
-
|
|
217
|
+
// Which chips a row may draw. The column chooser therefore still decides what is on screen, it
|
|
218
|
+
// just decides it per chip instead of per column.
|
|
219
|
+
const visible = new Set(table.getVisibleLeafColumns().map((column) => column.id));
|
|
244
220
|
// ⚠️ Measured on the ANSWER, not on the rows on screen. The filter travels to the server, so an
|
|
245
221
|
// empty answer under a filter means "nothing matches" while an empty answer without one means
|
|
246
222
|
// "this board has no cards" — two different sentences for the same zero.
|
|
@@ -328,8 +304,56 @@ export function BoardTable({
|
|
|
328
304
|
</DropdownMenuContent>
|
|
329
305
|
</DropdownMenu>
|
|
330
306
|
|
|
307
|
+
<DropdownMenu>
|
|
308
|
+
{/* ⚠️ **Sorting needs its own place now.** It used to live on the column headings, and
|
|
309
|
+
those are gone: one heading is left, and hanging four sort orders off it would be a
|
|
310
|
+
control nobody finds. The toolbar is where the other three view settings already are. */}
|
|
311
|
+
<DropdownMenuTrigger
|
|
312
|
+
aria-label={i18n.t("board.sortBy")}
|
|
313
|
+
className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted ${sorting.length === 0 ? "" : "bg-accent text-accent-foreground"}`}
|
|
314
|
+
>
|
|
315
|
+
<ArrowUpDown aria-hidden="true" className="size-4" />
|
|
316
|
+
</DropdownMenuTrigger>
|
|
317
|
+
<DropdownMenuContent align="start">
|
|
318
|
+
<DropdownMenuRadioGroup
|
|
319
|
+
value={
|
|
320
|
+
sorting[0] === undefined
|
|
321
|
+
? ""
|
|
322
|
+
: `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}`
|
|
323
|
+
}
|
|
324
|
+
onValueChange={(next) =>
|
|
325
|
+
setSorting(
|
|
326
|
+
next === ""
|
|
327
|
+
? []
|
|
328
|
+
: [{ id: next.split(":")[0] ?? "", desc: next.endsWith(":desc") }],
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
>
|
|
332
|
+
<DropdownMenuRadioItem value="">{i18n.t("board.sort.none")}</DropdownMenuRadioItem>
|
|
333
|
+
{/* ⚠️ Read off the COLUMNS like the grouping menu, not from a list written here: a
|
|
334
|
+
column that stops being sortable disappears from the menu on its own. */}
|
|
335
|
+
{sortable.flatMap((column) => [
|
|
336
|
+
<DropdownMenuRadioItem key={`${column.id}:asc`} value={`${column.id}:asc`}>
|
|
337
|
+
{i18n.t("board.sort.asc", { column: String(column.columnDef.header) })}
|
|
338
|
+
</DropdownMenuRadioItem>,
|
|
339
|
+
<DropdownMenuRadioItem key={`${column.id}:desc`} value={`${column.id}:desc`}>
|
|
340
|
+
{i18n.t("board.sort.desc", { column: String(column.columnDef.header) })}
|
|
341
|
+
</DropdownMenuRadioItem>,
|
|
342
|
+
])}
|
|
343
|
+
</DropdownMenuRadioGroup>
|
|
344
|
+
</DropdownMenuContent>
|
|
345
|
+
</DropdownMenu>
|
|
346
|
+
|
|
331
347
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
332
348
|
{[
|
|
349
|
+
sorting[0] === undefined
|
|
350
|
+
? null
|
|
351
|
+
: i18n.t(sorting[0].desc ? "board.sort.desc" : "board.sort.asc", {
|
|
352
|
+
column: String(
|
|
353
|
+
sortable.find((column) => column.id === sorting[0]?.id)?.columnDef.header ??
|
|
354
|
+
sorting[0].id,
|
|
355
|
+
),
|
|
356
|
+
}),
|
|
333
357
|
grouped === "none"
|
|
334
358
|
? null
|
|
335
359
|
: i18n.t("board.table.groupedBy", {
|
|
@@ -350,44 +374,26 @@ export function BoardTable({
|
|
|
350
374
|
|
|
351
375
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
352
376
|
<table className="w-full border-collapse text-left text-sm">
|
|
377
|
+
{/* ⚠️ ONE heading. The other five said what their column was called and nothing about the
|
|
378
|
+
row; a card without a due date filled a cell with emptiness, and a table half made of
|
|
379
|
+
emptiness is one people stop reading. What a value IS, its chip says. */}
|
|
353
380
|
<thead>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
<button
|
|
371
|
-
type="button"
|
|
372
|
-
onClick={header.column.getToggleSortingHandler()}
|
|
373
|
-
className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
374
|
-
>
|
|
375
|
-
{String(header.column.columnDef.header)}
|
|
376
|
-
<span aria-hidden="true" className="text-xs text-muted-foreground">
|
|
377
|
-
{header.column.getIsSorted() === "asc"
|
|
378
|
-
? "\u25b2"
|
|
379
|
-
: header.column.getIsSorted() === "desc"
|
|
380
|
-
? "\u25bc"
|
|
381
|
-
: ""}
|
|
382
|
-
</span>
|
|
383
|
-
</button>
|
|
384
|
-
) : (
|
|
385
|
-
String(header.column.columnDef.header)
|
|
386
|
-
)}
|
|
387
|
-
</th>
|
|
388
|
-
))}
|
|
389
|
-
</tr>
|
|
390
|
-
))}
|
|
381
|
+
<tr className="border-b">
|
|
382
|
+
{/* ⚠️ `aria-sort` survives the loss of the other headings. It is the only place the
|
|
383
|
+
TABLE itself says it is ordered; the quiet line in the toolbar says it to whoever
|
|
384
|
+
reads the toolbar, and a screen reader announcing a table does not. It reads
|
|
385
|
+
`none` while another column orders the rows, because that is what is true of this
|
|
386
|
+
one. */}
|
|
387
|
+
<th
|
|
388
|
+
scope="col"
|
|
389
|
+
aria-sort={
|
|
390
|
+
sorting[0]?.id !== "title" ? "none" : sorting[0].desc ? "descending" : "ascending"
|
|
391
|
+
}
|
|
392
|
+
className="px-2 py-3 font-medium"
|
|
393
|
+
>
|
|
394
|
+
{i18n.t("board.column.title")}
|
|
395
|
+
</th>
|
|
396
|
+
</tr>
|
|
391
397
|
</thead>
|
|
392
398
|
{/* ⚠️ One `tbody` PER GROUP, and the heading scopes to it. That is what actually
|
|
393
399
|
associates a heading with the rows under it: `scope="row"` binds to the heading's own
|
|
@@ -395,21 +401,23 @@ export function BoardTable({
|
|
|
395
401
|
through it reads to a screen reader as one long list with odd rows in it. */}
|
|
396
402
|
{groupsOf(rows).map((group) => (
|
|
397
403
|
<tbody key={group.key}>
|
|
398
|
-
{group.heading === undefined ? null : <GroupRow row={group.heading} span={
|
|
404
|
+
{group.heading === undefined ? null : <GroupRow row={group.heading} span={1} />}
|
|
399
405
|
{group.rows.map((row) => (
|
|
400
|
-
<tr
|
|
401
|
-
{row.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
{
|
|
411
|
-
|
|
412
|
-
|
|
406
|
+
<tr
|
|
407
|
+
key={row.id}
|
|
408
|
+
// ⚠️ A very light grey, and on the whole row. It says which row the pointer is on,
|
|
409
|
+
// which is the only thing a table this wide cannot say by itself.
|
|
410
|
+
className="border-b last:border-0 hover:bg-muted/40"
|
|
411
|
+
>
|
|
412
|
+
<td className="px-2 py-3">
|
|
413
|
+
<RowContent
|
|
414
|
+
row={row}
|
|
415
|
+
visible={visible}
|
|
416
|
+
onOpen={onOpen}
|
|
417
|
+
board={board}
|
|
418
|
+
columns={configured}
|
|
419
|
+
/>
|
|
420
|
+
</td>
|
|
413
421
|
</tr>
|
|
414
422
|
))}
|
|
415
423
|
</tbody>
|
|
@@ -489,12 +497,110 @@ function GroupRow({ row, span }: { row: TableRow; span: number }) {
|
|
|
489
497
|
}
|
|
490
498
|
|
|
491
499
|
/**
|
|
492
|
-
*
|
|
500
|
+
* One row: the title, and beside it only the values that are set.
|
|
493
501
|
*
|
|
494
|
-
* ⚠️ **
|
|
495
|
-
*
|
|
496
|
-
*
|
|
502
|
+
* ⚠️ **Always the same order** — column, dates, labels, what it waits for, then who it is for. The
|
|
503
|
+
* rows carry different numbers of chips, so without a fixed order the eye has no line to follow and
|
|
504
|
+
* every row has to be read from the start.
|
|
505
|
+
*
|
|
506
|
+
* ⚠️ **The circle is last, always.** It is the only round thing in the row; between the chips it
|
|
507
|
+
* breaks the line, and a row nobody is on simply ends earlier.
|
|
508
|
+
*
|
|
509
|
+
* ⚠️ **A chip is drawn only when its column is visible AND its value is set.** The column chooser
|
|
510
|
+
* therefore still works, it just hides a chip rather than an empty cell.
|
|
497
511
|
*/
|
|
498
|
-
function
|
|
499
|
-
|
|
512
|
+
function RowContent({
|
|
513
|
+
row,
|
|
514
|
+
visible,
|
|
515
|
+
onOpen,
|
|
516
|
+
board,
|
|
517
|
+
columns,
|
|
518
|
+
}: {
|
|
519
|
+
row: TableRow;
|
|
520
|
+
visible: Set<string>;
|
|
521
|
+
onOpen(taskId: string): void;
|
|
522
|
+
board: BoardHandle;
|
|
523
|
+
columns: BoardColumn[];
|
|
524
|
+
}) {
|
|
525
|
+
const i18n = useI18n();
|
|
526
|
+
const { task, columnTitle, blocking } = row.original;
|
|
527
|
+
|
|
528
|
+
const write = (input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>) => {
|
|
529
|
+
// ⚠️ Refused while a write is in flight, like every other surface of this board (#651).
|
|
530
|
+
if (board.isWriting) return;
|
|
531
|
+
void board.updateTask({ taskId: task.id, ...input, idempotencyKey: crypto.randomUUID() });
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<span className="flex items-center gap-2" style={{ paddingLeft: `${row.depth * 20}px` }}>
|
|
536
|
+
{row.getCanExpand() ? (
|
|
537
|
+
<button
|
|
538
|
+
type="button"
|
|
539
|
+
aria-label={i18n.t(row.getIsExpanded() ? "board.table.collapse" : "board.table.expand", {
|
|
540
|
+
row: task.title,
|
|
541
|
+
})}
|
|
542
|
+
onClick={row.getToggleExpandedHandler()}
|
|
543
|
+
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
544
|
+
>
|
|
545
|
+
{row.getIsExpanded() ? (
|
|
546
|
+
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
547
|
+
) : (
|
|
548
|
+
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
549
|
+
)}
|
|
550
|
+
</button>
|
|
551
|
+
) : (
|
|
552
|
+
<span className="size-4" />
|
|
553
|
+
)}
|
|
554
|
+
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
onClick={() => onOpen(task.id)}
|
|
558
|
+
className="min-w-0 flex-1 truncate rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
559
|
+
>
|
|
560
|
+
{task.title}
|
|
561
|
+
</button>
|
|
562
|
+
|
|
563
|
+
{visible.has("status") ? (
|
|
564
|
+
<StatusChip status={task.status} title={columnTitle} columns={columns} write={write} />
|
|
565
|
+
) : null}
|
|
566
|
+
|
|
567
|
+
{visible.has("start") && task.startDate !== null ? (
|
|
568
|
+
<BoardChip
|
|
569
|
+
label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
|
|
570
|
+
action={i18n.t("board.clearStart")}
|
|
571
|
+
onClick={() => write({ startDate: null })}
|
|
572
|
+
/>
|
|
573
|
+
) : null}
|
|
574
|
+
{visible.has("due") && task.dueDate !== null ? (
|
|
575
|
+
<BoardChip
|
|
576
|
+
label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
|
|
577
|
+
action={i18n.t("board.clearDue")}
|
|
578
|
+
onClick={() => write({ dueDate: null })}
|
|
579
|
+
/>
|
|
580
|
+
) : null}
|
|
581
|
+
|
|
582
|
+
{visible.has("labels")
|
|
583
|
+
? task.labels.map((label) => (
|
|
584
|
+
<BoardChip
|
|
585
|
+
key={label}
|
|
586
|
+
label={label}
|
|
587
|
+
action={i18n.t("board.removeLabel")}
|
|
588
|
+
onClick={() => write({ labels: task.labels.filter((keep) => keep !== label) })}
|
|
589
|
+
/>
|
|
590
|
+
))
|
|
591
|
+
: null}
|
|
592
|
+
|
|
593
|
+
{visible.has("dependsOn") && blocking !== null ? (
|
|
594
|
+
<BoardChip
|
|
595
|
+
label={blocking}
|
|
596
|
+
action={i18n.t("board.clearDependsOn")}
|
|
597
|
+
onClick={() => write({ dependsOn: null })}
|
|
598
|
+
/>
|
|
599
|
+
) : null}
|
|
600
|
+
|
|
601
|
+
{visible.has("assignee") && task.assigneeId !== null ? (
|
|
602
|
+
<AssigneeChip id={task.assigneeId} clear={() => write({ assigneeId: null })} />
|
|
603
|
+
) : null}
|
|
604
|
+
</span>
|
|
605
|
+
);
|
|
500
606
|
}
|