@anchrd/intel-ui 0.17.0 → 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 +2 -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-gantt/board-gantt.ts +1 -1
- package/src/board/board-kanban/board-kanban.ts +55 -15
- package/src/board/board-kanban/board-kanban.tsx +71 -25
- package/src/components/ui/kanban.tsx +78 -41
- package/src/i18n/de.json +5 -1
- package/src/i18n/en.json +5 -1
- package/src/i18n/es.json +5 -1
- package/src/node-table/node-table.tsx +3 -2
- package/src/resource-menu/resource-menu.tsx +26 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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.12.0",
|
|
37
37
|
"@assistant-ui/react": "^0.15.4",
|
|
38
38
|
"@assistant-ui/react-ai-sdk": "^1.4.4",
|
|
39
39
|
"@blocknote/core": "^0.52.1",
|
|
@@ -30,24 +30,40 @@ const PerSchedule = 5;
|
|
|
30
30
|
* ⚠️ And only for a zone somebody SET. `useChosenTimezone` answers `null` where nothing was chosen,
|
|
31
31
|
* and then this screen is exactly what it was — no browser guess dressed up as "your time", and no
|
|
32
32
|
* second time on every row for a reader who never asked for one.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ A paused agent gets a sentence over the list, because this is the tab somebody opens to find
|
|
35
|
+
* out WHETHER something runs (#203). Without it two places on one page said opposite things: the
|
|
36
|
+
* header read `Paused · no schedule fires`, and this list went on naming the next five times as if
|
|
37
|
+
* they would happen. The list is kept rather than emptied — what the agent WOULD do is still the
|
|
38
|
+
* answer to "what did I switch off" — but it may not read as a plan.
|
|
33
39
|
*/
|
|
34
40
|
export function AgentCalendar({
|
|
35
41
|
definition,
|
|
36
42
|
now,
|
|
43
|
+
paused,
|
|
37
44
|
}: {
|
|
38
45
|
definition: AgentDefinition | null;
|
|
39
46
|
// Handed in rather than read from the clock, so the view is a function of its input and a test
|
|
40
47
|
// does not have to travel in time to assert on it.
|
|
41
48
|
now: Date;
|
|
49
|
+
// ⚠️ Handed in for the same reason, and not read from `useAgentState` here: that hook needs the
|
|
50
|
+
// router context and a query client, and reaching for it would turn every test of this view into
|
|
51
|
+
// an integration test of two providers. The caller already holds the state for the header.
|
|
52
|
+
paused: boolean;
|
|
42
53
|
}) {
|
|
43
54
|
const i18n = useI18n();
|
|
44
55
|
const mine = useChosenTimezone();
|
|
45
56
|
const schedules = definition?.schedules ?? [];
|
|
46
57
|
const upcoming = schedules
|
|
47
|
-
|
|
58
|
+
// ⚠️ The schedule's own position rides along, and it is the only thing that separates two
|
|
59
|
+
// LITERALLY identical entries (#323). Two schedules with the same cron, zone and target are a
|
|
60
|
+
// definition somebody can save, and every other part of the key would be equal for them —
|
|
61
|
+
// including the fire time, which is computed from exactly those three.
|
|
62
|
+
.flatMap((schedule, position) =>
|
|
48
63
|
nextCronFires(schedule.cron, now, PerSchedule, schedule.timezone).map((at) => ({
|
|
49
64
|
at,
|
|
50
65
|
schedule,
|
|
66
|
+
position,
|
|
51
67
|
})),
|
|
52
68
|
)
|
|
53
69
|
.sort((left, right) => left.at.getTime() - right.at.getTime());
|
|
@@ -94,6 +110,15 @@ export function AgentCalendar({
|
|
|
94
110
|
{/* ⚠️ An agent with no schedule shows an empty calendar and nothing else (#254). The sentence
|
|
95
111
|
that used to stand here described the emptiness the reader is already looking at; the two
|
|
96
112
|
other states below are different, because each of them is a fact the list cannot show. */}
|
|
113
|
+
{/* ⚠️ Only where there is a list to qualify. A paused agent WITHOUT schedules shows the same
|
|
114
|
+
empty calendar it always did — the two states have to stay distinguishable, and a sentence
|
|
115
|
+
about times that do not happen, over no times at all, describes nothing. Same reason it
|
|
116
|
+
does not appear over `calendarUnreadable`: that one already says nothing is planned. */}
|
|
117
|
+
{paused && upcoming.length > 0 ? (
|
|
118
|
+
<p role="status" className="mt-4 rounded-lg border bg-muted px-4 py-3 text-sm">
|
|
119
|
+
{i18n.t("agent.calendarPaused")}
|
|
120
|
+
</p>
|
|
121
|
+
) : null}
|
|
97
122
|
{schedules.length === 0 ? null : upcoming.length === 0 ? (
|
|
98
123
|
// Schedules exist and none of them fires: every expression is unusable. A different answer
|
|
99
124
|
// from "no schedules", because it is a different problem and needs a different fix.
|
|
@@ -104,7 +129,7 @@ export function AgentCalendar({
|
|
|
104
129
|
<ol className="mt-4 space-y-2">
|
|
105
130
|
{upcoming.map((entry) => (
|
|
106
131
|
<li
|
|
107
|
-
key={`${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
|
|
132
|
+
key={`${entry.position}:${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
|
|
108
133
|
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
|
|
109
134
|
>
|
|
110
135
|
<CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
@@ -584,6 +584,20 @@ function ToolsSection({
|
|
|
584
584
|
<ul className="space-y-2">
|
|
585
585
|
{selected.map((handle) => {
|
|
586
586
|
const known = servers.data?.items.find((server) => server.handle === handle);
|
|
587
|
+
/**
|
|
588
|
+
* ⚠️ Silence about the question is not a negative answer to it (#350). While the list is
|
|
589
|
+
* loading — and after a failed read — `servers.data` is `undefined`, and `known` is
|
|
590
|
+
* then falsy for exactly the same reason as "the portal answered and this handle was
|
|
591
|
+
* not in it". Drawn as one, a row said `You no longer reach this server` for half a
|
|
592
|
+
* second on every visit: a statement about a REVOKED permission, and the sentence that
|
|
593
|
+
* makes somebody re-delegate, sign in to the portal again, or open a ticket.
|
|
594
|
+
*
|
|
595
|
+
* It is the same rule `adapters/tools` keeps for `portalConnected` and `reached` on the
|
|
596
|
+
* server side, where `packages/api/CLAUDE.md` spells it out — a missing field is a
|
|
597
|
+
* reason to say less, never to refuse more. This is the one place that said it the
|
|
598
|
+
* other way round.
|
|
599
|
+
*/
|
|
600
|
+
const answered = servers.isSuccess;
|
|
587
601
|
return (
|
|
588
602
|
<li
|
|
589
603
|
key={handle}
|
|
@@ -597,11 +611,15 @@ function ToolsSection({
|
|
|
597
611
|
</span>
|
|
598
612
|
{/* A server the signed-in person no longer reaches still stands in the definition,
|
|
599
613
|
and saying so beats drawing it as if it worked. The agent gets nothing from it
|
|
600
|
-
either — the catalog is cut against what the delegator reaches.
|
|
614
|
+
either — the catalog is cut against what the delegator reaches. But only once
|
|
615
|
+
the portal has actually answered: until then the row carries the handle and
|
|
616
|
+
nothing else, which is everything that is known about it. */}
|
|
601
617
|
<span className="text-xs text-muted-foreground">
|
|
602
618
|
{known
|
|
603
619
|
? i18n.t("tools.toolCount", { count: String(known.toolCount) })
|
|
604
|
-
:
|
|
620
|
+
: answered
|
|
621
|
+
? i18n.t("agent.toolServerUnavailable")
|
|
622
|
+
: null}
|
|
605
623
|
</span>
|
|
606
624
|
<button
|
|
607
625
|
type="button"
|
package/src/agent/agent.tsx
CHANGED
|
@@ -104,7 +104,14 @@ export function AgentPanel({ node }: { node: Node }) {
|
|
|
104
104
|
</TabsContent>
|
|
105
105
|
<TabsContent value="calendar" className="flex min-h-0 flex-col">
|
|
106
106
|
{tab === "calendar" ? (
|
|
107
|
-
<AgentCalendar
|
|
107
|
+
<AgentCalendar
|
|
108
|
+
definition={agent.definition}
|
|
109
|
+
now={new Date()}
|
|
110
|
+
// ⚠️ The same `state` the header reads, so the two cannot disagree about one agent —
|
|
111
|
+
// and the pause button writes its answer straight into this query key, so the note
|
|
112
|
+
// goes the moment somebody resumes, without this tab refetching (#203).
|
|
113
|
+
paused={state.data?.paused ?? false}
|
|
114
|
+
/>
|
|
108
115
|
) : null}
|
|
109
116
|
</TabsContent>
|
|
110
117
|
<TabsContent value="log" className="flex min-h-0 flex-col">
|
|
@@ -314,7 +321,25 @@ export function AgentActions({
|
|
|
314
321
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agent-runs", agentId] }),
|
|
315
322
|
});
|
|
316
323
|
|
|
317
|
-
|
|
324
|
+
/**
|
|
325
|
+
* ⚠️ One entry per TARGET, not per schedule (#323). Two schedules on the same flow — 08:00 and
|
|
326
|
+
* 17:00 — are the ordinary configuration this list exists for, and they produced two menu items
|
|
327
|
+
* nobody could choose between: both start the same run, and the time that tells them apart is
|
|
328
|
+
* not on either. They also shared a React key.
|
|
329
|
+
*
|
|
330
|
+
* Deduplicating is the honest reading of what this button does. "Run now" fires the target
|
|
331
|
+
* immediately; the schedule is the thing it is deliberately NOT waiting for, so which of the two
|
|
332
|
+
* entries somebody picked could never have meant anything. The alternative — two entries labelled
|
|
333
|
+
* with their cron — offers a choice that has no consequence.
|
|
334
|
+
*/
|
|
335
|
+
const targets = [
|
|
336
|
+
...new Map(
|
|
337
|
+
(definition?.schedules ?? []).map((schedule) => [
|
|
338
|
+
`${schedule.target.kind}:${schedule.target.id}`,
|
|
339
|
+
schedule.target,
|
|
340
|
+
]),
|
|
341
|
+
).values(),
|
|
342
|
+
];
|
|
318
343
|
const reason = denied
|
|
319
344
|
? i18n.t("agent.notPermitted")
|
|
320
345
|
: state.isError
|
|
@@ -489,7 +489,7 @@ export type GanttEdit = "move" | "start" | "end";
|
|
|
489
489
|
*
|
|
490
490
|
* ⚠️ It answers `null` for a gesture that changed no date, and that is half of what it is for. A
|
|
491
491
|
* bar picked up and put back is the commonest drag there is; sent anyway it would mint a version,
|
|
492
|
-
* bump the board and stand in the audit as work that did not happen — the same rule `
|
|
492
|
+
* bump the board and stand in the audit as work that did not happen — the same rule `dropOutcome`
|
|
493
493
|
* holds for a card dropped on its own place.
|
|
494
494
|
*
|
|
495
495
|
* ⚠️ A move shifts the dates the task HAS. A task with only a due date is drawn as the single day it
|
|
@@ -3,22 +3,42 @@ import type { MoveTask } from "@/board/board-data/board-data.types.ts";
|
|
|
3
3
|
import type { KanbanDrop } from "@/components/ui/kanban.tsx";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* What a drop
|
|
6
|
+
* What a drop DID: the one `board_task_move` it is worth, or the reason there is none.
|
|
7
7
|
*
|
|
8
|
+
* ⚠️ Three answers rather than a move or a `null`, and the third one is the whole of
|
|
9
|
+
* anchrd/intel#345. Two of the ways a drag ends write nothing, and they are not the same event to
|
|
10
|
+
* whoever is being told about it: a card let go on its own place stayed put, and a card let go over
|
|
11
|
+
* a lane the board cannot write into was REFUSED. Announced as one — or, as it was, announced as a
|
|
12
|
+
* move that never happened — the difference is exactly the one a reader needs, because since
|
|
13
|
+
* anchrd/intel#315 the announcement is the only feedback the keyboard path has.
|
|
14
|
+
*/
|
|
15
|
+
export type DropOutcome =
|
|
16
|
+
// ⚠️ `status` is optional on the wire — a move may be a reparent and name no column — but a drop
|
|
17
|
+
// always lands in a lane and always names it. Narrowed here so the sentence a reader hears can
|
|
18
|
+
// name the lane the card really went into rather than the one the pointer was last over.
|
|
19
|
+
| { kind: "move"; move: MoveTask & { status: string } }
|
|
20
|
+
// The status the drop resolved to: drawn by the board, and not one it may write into. Carried
|
|
21
|
+
// rather than looked up again, so the sentence a reader hears names the lane that said no.
|
|
22
|
+
| { kind: "refused"; status: string }
|
|
23
|
+
// Nothing to write and nothing refused: the card ends where it began, or it was let go over
|
|
24
|
+
// something the board does not draw.
|
|
25
|
+
| { kind: "unchanged" };
|
|
26
|
+
|
|
27
|
+
/**
|
|
8
28
|
* ⚠️ Neighbours, never an index. The server mints the fractional key between the two tasks named
|
|
9
29
|
* here (#285), so one drag writes one task and renumbers nothing — the reason a board is addressed
|
|
10
30
|
* by task id and not by position at all. A UI that sent a position would have to send the whole
|
|
11
31
|
* column with it to be unambiguous.
|
|
12
32
|
*
|
|
13
|
-
* ⚠️
|
|
14
|
-
* reports a drop wherever the pointer was let go, including back on the card's own place;
|
|
15
|
-
* a move for that would write a version, bump the board and show up in the audit as work
|
|
16
|
-
* not happen.
|
|
33
|
+
* ⚠️ Nothing to write for a drag that changed nothing, and that is half the point of this function.
|
|
34
|
+
* dnd-kit reports a drop wherever the pointer was let go, including back on the card's own place;
|
|
35
|
+
* sending a move for that would write a version, bump the board and show up in the audit as work
|
|
36
|
+
* that did not happen.
|
|
17
37
|
*
|
|
18
38
|
* `lanes` is what the board DRAWS — root tasks per column, in order. Subtasks are not lane cards
|
|
19
39
|
* (they are a counter on their parent), so they are not in it and cannot be dropped on either.
|
|
20
40
|
*/
|
|
21
|
-
export function
|
|
41
|
+
export function dropOutcome(
|
|
22
42
|
drop: KanbanDrop,
|
|
23
43
|
lanes: Map<string, BoardTask[]>,
|
|
24
44
|
byId: Map<string, BoardTask>,
|
|
@@ -27,22 +47,39 @@ export function moveFromDrop(
|
|
|
27
47
|
// not be somewhere a card can be MOVED. The server refuses a task in a status the board does not
|
|
28
48
|
// have, so a drop there would be a refusal, an invalidation and no word to the reader.
|
|
29
49
|
writable: ReadonlySet<string>,
|
|
30
|
-
):
|
|
50
|
+
): DropOutcome {
|
|
31
51
|
const active = byId.get(drop.activeId);
|
|
32
52
|
// ⚠️ Let go over itself is the commonest drag of all — a click that travelled five pixels — and
|
|
33
53
|
// it has to be nothing before any of the arithmetic below runs. Read as "dropped on a card", it
|
|
34
54
|
// would compute a landing place for a card that never left, which is the shape of a write that
|
|
35
55
|
// says work happened.
|
|
36
|
-
|
|
56
|
+
//
|
|
57
|
+
// ⚠️ And it is `unchanged` even when the card stands in a lane that cannot be written into: what
|
|
58
|
+
// happened there is that a card came back to its own place, not that a lane turned a card away.
|
|
59
|
+
if (drop.overId === null || drop.overId === drop.activeId || active === undefined)
|
|
60
|
+
return { kind: "unchanged" };
|
|
37
61
|
|
|
38
62
|
// The drop landed either on a column's empty space — then its id is the status — or on a card,
|
|
39
63
|
// and then the column is that card's. A card the board does not draw (a subtask, or one filtered
|
|
40
64
|
// away) resolves to neither and the drag is a no-op rather than a guess.
|
|
41
65
|
const overTask = byId.get(drop.overId);
|
|
42
66
|
const status = overTask?.status ?? (lanes.has(drop.overId) ? drop.overId : null);
|
|
43
|
-
if (status === null
|
|
67
|
+
if (status === null) return { kind: "unchanged" };
|
|
44
68
|
|
|
45
69
|
const lane = lanes.get(status) ?? [];
|
|
70
|
+
// ⚠️ "Is this a card the board draws" is read BEFORE the refusal, and the order is the whole of
|
|
71
|
+
// it. `byId` holds subtasks too — they are counters on their parent, not lane cards — and one of
|
|
72
|
+
// them left in a status the board no longer lists resolves to a status without ever standing in
|
|
73
|
+
// that lane. Refused first, such a drop would be announced as a lane turning the card away while
|
|
74
|
+
// no lane was involved at all. Nothing renders a subtask as a droppable today; this is a guard on
|
|
75
|
+
// the order, so that the day one does, the sentence does not start lying.
|
|
76
|
+
if (overTask !== undefined && !lane.some((task) => task.id === overTask.id))
|
|
77
|
+
return { kind: "unchanged" };
|
|
78
|
+
// ⚠️ Refused BEFORE the request, and said so. The lane is drawn and its cards are still sortables,
|
|
79
|
+
// so a drop lands here on both the pointer path and the keyboard one; the server would answer a
|
|
80
|
+
// task in a status the board does not have with a refusal.
|
|
81
|
+
if (!writable.has(status)) return { kind: "refused", status };
|
|
82
|
+
|
|
46
83
|
const from = lane.findIndex((task) => task.id === active.id);
|
|
47
84
|
const rest = lane.filter((task) => task.id !== active.id);
|
|
48
85
|
|
|
@@ -51,8 +88,8 @@ export function moveFromDrop(
|
|
|
51
88
|
// Let go over the column rather than over a card: the end of it.
|
|
52
89
|
landing = rest.length;
|
|
53
90
|
} else {
|
|
91
|
+
// In the lane (checked above) and not the active card (checked at the top), so it is in `rest`.
|
|
54
92
|
const overIndex = rest.findIndex((task) => task.id === overTask.id);
|
|
55
|
-
if (overIndex === -1) return null;
|
|
56
93
|
// Dragged downwards inside its own column, the card takes the place BELOW the one it was let go
|
|
57
94
|
// over — the same reading `arrayMove` has, and the one the pointer suggests. Every other case
|
|
58
95
|
// puts it above.
|
|
@@ -61,13 +98,16 @@ export function moveFromDrop(
|
|
|
61
98
|
}
|
|
62
99
|
|
|
63
100
|
// Re-inserting where it came from is the arrangement it already had.
|
|
64
|
-
if (from !== -1 && landing === from) return
|
|
101
|
+
if (from !== -1 && landing === from) return { kind: "unchanged" };
|
|
65
102
|
|
|
66
103
|
return {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
104
|
+
kind: "move",
|
|
105
|
+
move: {
|
|
106
|
+
taskId: active.id,
|
|
107
|
+
status,
|
|
108
|
+
afterTaskId: rest[landing - 1]?.id ?? null,
|
|
109
|
+
beforeTaskId: rest[landing]?.id ?? null,
|
|
110
|
+
},
|
|
71
111
|
};
|
|
72
112
|
}
|
|
73
113
|
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
KanbanProvider,
|
|
13
13
|
} from "@/components/ui/kanban.tsx";
|
|
14
14
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
15
|
-
import {
|
|
15
|
+
import { dropOutcome, subtaskCount } from "./board-kanban.ts";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* The board as lanes (anchrd/intel#286).
|
|
@@ -98,6 +98,33 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
|
|
|
98
98
|
[lanes],
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* What a finished drag is announced as — and it is the OUTCOME that is announced, not what the
|
|
103
|
+
* pointer was last over (anchrd/intel#345).
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ Asked of the same function the write is asked of, which is the point: a second reading of a
|
|
106
|
+
* drop would be a second opinion about it, and the one a reader hears would be the one nothing
|
|
107
|
+
* checks. Two of the three answers write nothing, and each says so in its own words — a lane that
|
|
108
|
+
* cannot take the card names itself, and a card that came back to its own place says it stayed.
|
|
109
|
+
* Announced as "dropped into X" (which is what all three used to say), a keyboard user who never
|
|
110
|
+
* sees the card jump has nothing to correct it with.
|
|
111
|
+
*/
|
|
112
|
+
const dropped = (activeId: string, overId: string | null) => {
|
|
113
|
+
const active = board.byId.get(activeId);
|
|
114
|
+
const task = active?.title ?? "";
|
|
115
|
+
const from = columnName(active?.status ?? "", columns);
|
|
116
|
+
const outcome = dropOutcome({ activeId, overId }, lanes, board.byId, writable);
|
|
117
|
+
if (outcome.kind === "move")
|
|
118
|
+
return i18n.t("board.dnd.end", { task, lane: columnName(outcome.move.status, columns) });
|
|
119
|
+
if (outcome.kind === "refused")
|
|
120
|
+
return i18n.t("board.dnd.endRefused", {
|
|
121
|
+
task,
|
|
122
|
+
lane: columnName(outcome.status, columns),
|
|
123
|
+
from,
|
|
124
|
+
});
|
|
125
|
+
return i18n.t("board.dnd.endUnchanged", { task, lane: from });
|
|
126
|
+
};
|
|
127
|
+
|
|
101
128
|
const card = (task: BoardTask) => (
|
|
102
129
|
<BoardCardBody
|
|
103
130
|
task={task}
|
|
@@ -123,28 +150,31 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
|
|
|
123
150
|
lane: over === null ? "" : laneName(String(over.id), board, columns),
|
|
124
151
|
}),
|
|
125
152
|
onDragEnd: ({ active, over }) =>
|
|
126
|
-
|
|
127
|
-
task: board.byId.get(String(active.id))?.title ?? "",
|
|
128
|
-
lane: over === null ? "" : laneName(String(over.id), board, columns),
|
|
129
|
-
}),
|
|
153
|
+
dropped(String(active.id), over === null ? null : String(over.id)),
|
|
130
154
|
onDragCancel: ({ active }) =>
|
|
131
155
|
i18n.t("board.dnd.cancel", { task: board.byId.get(String(active.id))?.title ?? "" }),
|
|
132
156
|
}}
|
|
157
|
+
instructions={i18n.t("board.dnd.instructions")}
|
|
133
158
|
overlay={(activeId) => {
|
|
134
159
|
const task = board.byId.get(activeId);
|
|
135
160
|
return task ? (
|
|
136
|
-
<div
|
|
161
|
+
<div
|
|
162
|
+
data-slot="board-card-overlay"
|
|
163
|
+
className="w-72 rounded-lg border bg-card p-3 shadow-lg ring-2 ring-ring"
|
|
164
|
+
>
|
|
137
165
|
{card(task)}
|
|
138
166
|
</div>
|
|
139
167
|
) : null;
|
|
140
168
|
}}
|
|
141
169
|
// ⚠️ Exactly one write per drag, and only when the card actually went somewhere.
|
|
142
|
-
// `
|
|
143
|
-
//
|
|
144
|
-
//
|
|
170
|
+
// `dropOutcome` answers with something other than a move for a drop back onto its own place
|
|
171
|
+
// and for a lane the board cannot write into, and the mutation names the NEIGHBOURS rather
|
|
172
|
+
// than an index — the server mints the order key between them, so one drag writes one task
|
|
173
|
+
// and the other cards are not touched (#285). The same answer is what the reader is told
|
|
174
|
+
// about (anchrd/intel#345), so the sentence and the write can never drift apart.
|
|
145
175
|
onDrop={(drop) => {
|
|
146
|
-
const
|
|
147
|
-
if (move) board.move.mutate(move);
|
|
176
|
+
const outcome = dropOutcome(drop, lanes, board.byId, writable);
|
|
177
|
+
if (outcome.kind === "move") board.move.mutate(outcome.move);
|
|
148
178
|
}}
|
|
149
179
|
>
|
|
150
180
|
{(column) => (
|
|
@@ -171,18 +201,27 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
|
|
|
171
201
|
<KanbanCard
|
|
172
202
|
key={task.id}
|
|
173
203
|
id={task.id}
|
|
174
|
-
|
|
204
|
+
roleDescription={i18n.t("board.card.roleDescription")}
|
|
175
205
|
>
|
|
176
|
-
{/*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
{/* ⚠️ ONE control per card, and it means two things — click or Enter opens the
|
|
207
|
+
task, Space picks it up to be moved by the arrow keys (anchrd/intel#315).
|
|
208
|
+
`control` is what makes the second half true: it carries dnd-kit's activator
|
|
209
|
+
ref, so the keyboard sensor accepts a key press here and nowhere else in the
|
|
210
|
+
card, and the `aria-roledescription`/`aria-describedby` that say so.
|
|
211
|
+
|
|
212
|
+
The pointer sensor's four-pixel threshold is what keeps a click from counting
|
|
213
|
+
as a drag of nothing. */}
|
|
214
|
+
{({ ref, ...control }) => (
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
ref={ref}
|
|
218
|
+
{...control}
|
|
219
|
+
className="w-full rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
220
|
+
onClick={() => select(task.id)}
|
|
221
|
+
>
|
|
222
|
+
{card(task)}
|
|
223
|
+
</button>
|
|
224
|
+
)}
|
|
186
225
|
</KanbanCard>
|
|
187
226
|
))}
|
|
188
227
|
{(lanes.get(column.id) ?? []).length === 0 ? (
|
|
@@ -200,12 +239,19 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
|
|
|
200
239
|
);
|
|
201
240
|
}
|
|
202
241
|
|
|
242
|
+
// What stands over a lane: the column's name, and the raw status id for a lane the board no longer
|
|
243
|
+
// lists — the same fallback `labelOf` makes, so one status is not called two things on one screen.
|
|
244
|
+
function columnName(statusId: string, columns: { id: string; name: string }[]): string {
|
|
245
|
+
return columns.find((column) => column.id === statusId)?.name ?? statusId;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// What a drag is currently OVER, read as a lane: a card answers with its own lane, a column with
|
|
249
|
+
// itself. Only the running commentary needs this — what a finished drag is announced as comes from
|
|
250
|
+
// the outcome, not from the thing under the pointer (anchrd/intel#345).
|
|
203
251
|
function laneName(
|
|
204
252
|
overId: string,
|
|
205
253
|
board: BoardViewProps["board"],
|
|
206
254
|
columns: { id: string; name: string }[],
|
|
207
255
|
): string {
|
|
208
|
-
|
|
209
|
-
const statusId = task?.status ?? overId;
|
|
210
|
-
return columns.find((column) => column.id === statusId)?.name ?? statusId;
|
|
256
|
+
return columnName(board.byId.get(overId)?.status ?? overId, columns);
|
|
211
257
|
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type Announcements,
|
|
3
3
|
closestCorners,
|
|
4
4
|
DndContext,
|
|
5
|
+
type DraggableAttributes,
|
|
5
6
|
DragOverlay,
|
|
6
7
|
type DragStartEvent,
|
|
7
8
|
KeyboardSensor,
|
|
@@ -42,13 +43,14 @@ import { cn } from "@/lib/utils";
|
|
|
42
43
|
* ⚠️ **The screen reader announcements are handed in.** The originals are English string literals
|
|
43
44
|
* in the component; Intel speaks three languages and its text comes from `useI18n()`.
|
|
44
45
|
*
|
|
45
|
-
* ⚠️ **The card is not itself a `role="button"
|
|
46
|
-
* onto the card, which contributes `role="button"` and
|
|
47
|
-
* be OPENED then holds a real button inside that one
|
|
48
|
-
* name from the same contents, and an Enter that means
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
46
|
+
* ⚠️ **The card is not itself a `role="button"`, and it holds exactly ONE tab stop.** The original
|
|
47
|
+
* spread `useSortable().attributes` onto the card, which contributes `role="button"` and
|
|
48
|
+
* `tabIndex={0}` — and a card that also has to be OPENED then holds a real button inside that one:
|
|
49
|
+
* two tab stops computing the same accessible name from the same contents, and an Enter that means
|
|
50
|
+
* "start dragging" outside and "open this" inside (anchrd/intel#286). This version keeps the
|
|
51
|
+
* `listeners` on the card so a pointer still drags it from anywhere, and hands the ACTIVATOR to the
|
|
52
|
+
* caller's own control instead of minting a second button beside it (anchrd/intel#315). What
|
|
53
|
+
* separates the two meanings is then the key, not the element: see `KanbanProvider`.
|
|
52
54
|
*/
|
|
53
55
|
|
|
54
56
|
export interface KanbanColumn {
|
|
@@ -112,16 +114,33 @@ export function KanbanCards({ id, children }: { id: string; children: ReactNode
|
|
|
112
114
|
);
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* What a card hands to the one control inside it: the activator ref and dnd-kit's ARIA attributes.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ It is spread onto a control the CALLER renders, which is the whole point — the alternative is
|
|
121
|
+
* a handle of the primitive's own beside it, and that is the second tab stop anchrd/intel#315 is
|
|
122
|
+
* about. `role` and `tabIndex` are in here and are what a native `<button>` already is; what is not
|
|
123
|
+
* already there is `aria-roledescription` and the `aria-describedby` pointing at dnd-kit's keyboard
|
|
124
|
+
* instructions, and those are the half a reader needs to be told the control can also be picked up.
|
|
125
|
+
*/
|
|
126
|
+
export type KanbanCardControl = DraggableAttributes & {
|
|
127
|
+
ref: (node: HTMLElement | null) => void;
|
|
128
|
+
};
|
|
129
|
+
|
|
115
130
|
export function KanbanCard({
|
|
116
131
|
id,
|
|
117
|
-
|
|
132
|
+
roleDescription,
|
|
118
133
|
children,
|
|
119
134
|
className,
|
|
120
135
|
}: {
|
|
121
136
|
id: string;
|
|
122
|
-
// What
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
// What a reader is told this card IS, past its accessible name — dnd-kit's `aria-roledescription`,
|
|
138
|
+
// whose own default is the English literal "draggable". Handed in because text comes from
|
|
139
|
+
// `useI18n()` and only the caller has it.
|
|
140
|
+
roleDescription: string;
|
|
141
|
+
// A function, not a node: the one control in a card has to receive `control`, and a primitive that
|
|
142
|
+
// rendered its own control instead would be back to two tab stops.
|
|
143
|
+
children: (control: KanbanCardControl) => ReactNode;
|
|
125
144
|
className?: string;
|
|
126
145
|
}) {
|
|
127
146
|
const {
|
|
@@ -132,22 +151,24 @@ export function KanbanCard({
|
|
|
132
151
|
transition,
|
|
133
152
|
transform,
|
|
134
153
|
isDragging,
|
|
135
|
-
} = useSortable({ id });
|
|
154
|
+
} = useSortable({ id, attributes: { roleDescription } });
|
|
136
155
|
return (
|
|
137
156
|
<div
|
|
138
157
|
ref={setNodeRef}
|
|
139
158
|
style={{ transition, transform: CSS.Transform.toString(transform) }}
|
|
140
159
|
// ⚠️ `listeners` here, `attributes` NOT. The pointer sensor has to see a press anywhere on the
|
|
141
160
|
// card — a kanban whose cards can only be dragged by a grip is a kanban nobody drags — but
|
|
142
|
-
// `attributes`
|
|
143
|
-
//
|
|
144
|
-
// keyboard drag from the handle, one tab stop each.
|
|
161
|
+
// `attributes` carries `role="button"` and `tabIndex`, and those on the card would wrap the
|
|
162
|
+
// control that OPENS it in a second button.
|
|
145
163
|
//
|
|
146
|
-
// ⚠️
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
164
|
+
// ⚠️ Handing the activator down only holds because something claims to BE it. dnd-kit's
|
|
165
|
+
// `KeyboardSensor` opens with `if (activator && event.target !== activator) return false` — a
|
|
166
|
+
// guard that does not exist until `setActivatorNodeRef` has been given a node. Unclaimed, the
|
|
167
|
+
// keyboard sensor accepts a key press from anywhere under these listeners, which is how a card
|
|
168
|
+
// ended up impossible to open by keyboard (anchrd/intel#286). The pointer drag is unaffected
|
|
169
|
+
// either way: `PointerSensor` never consults the activator node.
|
|
150
170
|
{...listeners}
|
|
171
|
+
data-slot="kanban-card"
|
|
151
172
|
className={cn(
|
|
152
173
|
"flex cursor-grab items-start gap-1 rounded-lg border bg-card p-3 text-left shadow-sm",
|
|
153
174
|
// Left where it was, faded, rather than removed: a lane that reflows while a card is over
|
|
@@ -156,35 +177,47 @@ export function KanbanCard({
|
|
|
156
177
|
className,
|
|
157
178
|
)}
|
|
158
179
|
>
|
|
159
|
-
{/*
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
a guard that only exists once something has claimed to BE the activator. Unclaimed, the
|
|
165
|
-
keyboard sensor accepts a key press from anywhere under the listeners, so Enter on the
|
|
166
|
-
card's own "open" button started a drag and swallowed the click. The pointer drag is
|
|
167
|
-
unaffected: `PointerSensor` never consults the activator node. */}
|
|
168
|
-
<button
|
|
169
|
-
type="button"
|
|
170
|
-
ref={setActivatorNodeRef}
|
|
171
|
-
aria-label={dragLabel}
|
|
172
|
-
{...listeners}
|
|
173
|
-
{...attributes}
|
|
174
|
-
className="mt-0.5 shrink-0 cursor-grab rounded text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
175
|
-
>
|
|
176
|
-
<GripVertical aria-hidden="true" className="size-4" />
|
|
177
|
-
</button>
|
|
178
|
-
<div className="min-w-0 flex-1">{children}</div>
|
|
180
|
+
{/* Decoration, and deliberately not a control: it says "this can be taken hold of" to whoever
|
|
181
|
+
uses a pointer, and a pointer can take hold of the whole card anyway. As a button it was a
|
|
182
|
+
tab stop of its own on every card in the lane. */}
|
|
183
|
+
<GripVertical aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
184
|
+
<div className="min-w-0 flex-1">{children({ ...attributes, ref: setActivatorNodeRef })}</div>
|
|
179
185
|
</div>
|
|
180
186
|
);
|
|
181
187
|
}
|
|
182
188
|
|
|
189
|
+
/**
|
|
190
|
+
* ⚠️ Space picks a card up, Enter does not — and that is the whole of anchrd/intel#315.
|
|
191
|
+
*
|
|
192
|
+
* dnd-kit's default is `start: ['Space', 'Enter']`, which forces the activator to be an element of
|
|
193
|
+
* its own: a control that is also the card's "open" button cannot answer both keys with two
|
|
194
|
+
* different things. With the start key narrowed to Space, the two meanings are told apart by the
|
|
195
|
+
* KEY instead of by the element, and a card costs one tab stop rather than two — fifty cards, fifty
|
|
196
|
+
* stops instead of a hundred.
|
|
197
|
+
*
|
|
198
|
+
* ⚠️ `start` is the ONLY list that is narrowed. `end` and `cancel` are dnd-kit's defaults spelled
|
|
199
|
+
* out — Space, Enter and **Tab** put a card down, Escape leaves it where it was — and dropping one
|
|
200
|
+
* of them here is not a smaller change than it looks. Tab is neither an end key nor a cancel key
|
|
201
|
+
* once it is missing from this list, and `defaultKeyboardCoordinateGetter` answers nothing for it,
|
|
202
|
+
* so nothing calls `preventDefault()`: the focus walks on to the next card while the first one is
|
|
203
|
+
* still in the air and still following the arrow keys.
|
|
204
|
+
*
|
|
205
|
+
* Enter stays an END key, and it cannot open the card while ending a move: `handleEnd` calls
|
|
206
|
+
* `preventDefault()` before the browser turns the key press into a click. Somebody holding a card
|
|
207
|
+
* who presses the key they associate with "yes" should put it down, not be told nothing happened.
|
|
208
|
+
*/
|
|
209
|
+
const kanbanKeyboardCodes = {
|
|
210
|
+
start: ["Space"],
|
|
211
|
+
cancel: ["Escape"],
|
|
212
|
+
end: ["Space", "Enter", "Tab"],
|
|
213
|
+
};
|
|
214
|
+
|
|
183
215
|
export function KanbanProvider({
|
|
184
216
|
columns,
|
|
185
217
|
itemsByColumn,
|
|
186
218
|
children,
|
|
187
219
|
announcements,
|
|
220
|
+
instructions,
|
|
188
221
|
overlay,
|
|
189
222
|
onDrop,
|
|
190
223
|
className,
|
|
@@ -196,6 +229,10 @@ export function KanbanProvider({
|
|
|
196
229
|
itemsByColumn: Map<string, string[]>;
|
|
197
230
|
children: (column: KanbanColumn) => ReactNode;
|
|
198
231
|
announcements: Announcements;
|
|
232
|
+
// ⚠️ Not optional, and not a nicety. dnd-kit's built-in text is an English literal naming the
|
|
233
|
+
// default keys, and this board answers different ones. A card whose `aria-describedby` says
|
|
234
|
+
// "press space or enter" while Enter opens it describes a widget that is not there.
|
|
235
|
+
instructions: string;
|
|
199
236
|
overlay(activeId: string): ReactNode;
|
|
200
237
|
onDrop(drop: KanbanDrop): void;
|
|
201
238
|
className?: string;
|
|
@@ -205,12 +242,12 @@ export function KanbanProvider({
|
|
|
205
242
|
// ⚠️ A distance before a drag starts, and it is not decoration: without it every click on a
|
|
206
243
|
// card is a drag of zero pixels, and the card can then no longer be opened by clicking it.
|
|
207
244
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
208
|
-
useSensor(KeyboardSensor),
|
|
245
|
+
useSensor(KeyboardSensor, { keyboardCodes: kanbanKeyboardCodes }),
|
|
209
246
|
);
|
|
210
247
|
|
|
211
248
|
return (
|
|
212
249
|
<DndContext
|
|
213
|
-
accessibility={{ announcements }}
|
|
250
|
+
accessibility={{ announcements, screenReaderInstructions: { draggable: instructions } }}
|
|
214
251
|
// Corners rather than centres: a tall card over a short one wins on centre distance even
|
|
215
252
|
// when the pointer is nowhere near it.
|
|
216
253
|
collisionDetection={closestCorners}
|
package/src/i18n/de.json
CHANGED
|
@@ -373,6 +373,7 @@
|
|
|
373
373
|
"agent.calendarZones": "Jede Zeit steht in der Zeitzone ihres eigenen Zeitplans, denn dann läuft der Agent. Weicht deine eigene Zeitzone ab, steht sie daneben.",
|
|
374
374
|
"agent.calendarYourTime": "{when} bei dir",
|
|
375
375
|
"agent.calendarUnreadable": "Keiner der Zeitpläne dieses Agenten lässt sich als Cron-Ausdruck lesen, es ist also nichts geplant.",
|
|
376
|
+
"agent.calendarPaused": "Dieser Agent ist pausiert, keiner dieser Läufe findet statt. Sie zeigen, was er täte, sobald er fortgesetzt wird.",
|
|
376
377
|
"agent.log": "Läufe",
|
|
377
378
|
"agent.logEmpty": "Dieser Agent ist noch nicht gelaufen.",
|
|
378
379
|
"agent.logFailed": "Die Läufe konnten nicht gelesen werden. Vielleicht ist die Agenten-Laufzeit von hier aus nicht erreichbar.",
|
|
@@ -507,7 +508,10 @@
|
|
|
507
508
|
"board.dnd.start": "{task} aufgenommen",
|
|
508
509
|
"board.dnd.over": "{task} über {lane}",
|
|
509
510
|
"board.dnd.end": "{task} in {lane} abgelegt",
|
|
511
|
+
"board.dnd.endUnchanged": "{task} wurde nicht verschoben und steht weiterhin in {lane}",
|
|
512
|
+
"board.dnd.endRefused": "{lane} nimmt keine Karten mehr auf, {task} wurde nicht verschoben und steht weiterhin in {from}",
|
|
510
513
|
"board.dnd.cancel": "Ablegen von {task} abgebrochen",
|
|
511
|
-
"board.
|
|
514
|
+
"board.dnd.instructions": "Mit der Eingabetaste diese Aufgabe öffnen. Zum Verschieben stattdessen die Leertaste darauf drücken und sie mit den Pfeiltasten an eine andere Position oder in eine andere Spalte bringen, dann mit Leertaste, Eingabetaste oder Tabulator ablegen oder mit Escape dort lassen, wo sie war.",
|
|
515
|
+
"board.card.roleDescription": "Aufgabe, verschiebbar",
|
|
512
516
|
"board.statusTerminal": "Gilt als erledigt"
|
|
513
517
|
}
|
package/src/i18n/en.json
CHANGED
|
@@ -373,6 +373,7 @@
|
|
|
373
373
|
"agent.calendarZones": "Each time is shown in the timezone of its own schedule, because that is when the agent runs. Where your own timezone differs, it is added beside it.",
|
|
374
374
|
"agent.calendarYourTime": "{when} your time",
|
|
375
375
|
"agent.calendarUnreadable": "None of this agent's schedules can be read as a cron expression, so nothing is planned.",
|
|
376
|
+
"agent.calendarPaused": "This agent is paused, so none of these runs will happen. They are what it would do once it is resumed.",
|
|
376
377
|
"agent.log": "Runs",
|
|
377
378
|
"agent.logEmpty": "This agent has not run yet.",
|
|
378
379
|
"agent.logFailed": "The runs could not be read. The agent runtime may be unreachable from here.",
|
|
@@ -507,7 +508,10 @@
|
|
|
507
508
|
"board.dnd.start": "Picked up {task}",
|
|
508
509
|
"board.dnd.over": "{task} is over {lane}",
|
|
509
510
|
"board.dnd.end": "Dropped {task} into {lane}",
|
|
511
|
+
"board.dnd.endUnchanged": "{task} was not moved and is still in {lane}",
|
|
512
|
+
"board.dnd.endRefused": "{lane} no longer takes cards, {task} was not moved and is still in {from}",
|
|
510
513
|
"board.dnd.cancel": "Dropping {task} was cancelled",
|
|
511
|
-
"board.
|
|
514
|
+
"board.dnd.instructions": "Press enter to open this task. To move it instead, press the space bar on it and use the arrow keys to take it to another position or lane, then press space, enter or tab to put it down, or escape to leave it where it was.",
|
|
515
|
+
"board.card.roleDescription": "task, movable",
|
|
512
516
|
"board.statusTerminal": "Means done"
|
|
513
517
|
}
|
package/src/i18n/es.json
CHANGED
|
@@ -373,6 +373,7 @@
|
|
|
373
373
|
"agent.calendarZones": "Cada hora se muestra en la zona horaria de su propio horario, porque es cuando se ejecuta el agente. Si tu propia zona horaria difiere, se añade al lado.",
|
|
374
374
|
"agent.calendarYourTime": "{when} en tu hora",
|
|
375
375
|
"agent.calendarUnreadable": "Ninguno de los horarios de este agente se puede leer como expresión cron, así que no hay nada planificado.",
|
|
376
|
+
"agent.calendarPaused": "Este agente está en pausa, así que ninguna de estas ejecuciones ocurrirá. Muestran lo que haría al reanudarlo.",
|
|
376
377
|
"agent.log": "Ejecuciones",
|
|
377
378
|
"agent.logEmpty": "Este agente todavía no se ha ejecutado.",
|
|
378
379
|
"agent.logFailed": "Las ejecuciones no se han podido leer. Puede que el entorno de ejecución de agentes no sea accesible desde aquí.",
|
|
@@ -507,7 +508,10 @@
|
|
|
507
508
|
"board.dnd.start": "{task} tomada",
|
|
508
509
|
"board.dnd.over": "{task} sobre {lane}",
|
|
509
510
|
"board.dnd.end": "{task} soltada en {lane}",
|
|
511
|
+
"board.dnd.endUnchanged": "{task} no se movió y sigue en {lane}",
|
|
512
|
+
"board.dnd.endRefused": "{lane} ya no admite tarjetas, {task} no se movió y sigue en {from}",
|
|
510
513
|
"board.dnd.cancel": "Se canceló soltar {task}",
|
|
511
|
-
"board.
|
|
514
|
+
"board.dnd.instructions": "Pulsa intro para abrir esta tarea. Para moverla, pulsa la barra espaciadora sobre ella y llévala con las flechas a otra posición o columna; después suéltala con espacio, intro o tabulador, o pulsa escape para dejarla donde estaba.",
|
|
515
|
+
"board.card.roleDescription": "tarea, movible",
|
|
512
516
|
"board.statusTerminal": "Cuenta como hecho"
|
|
513
517
|
}
|
|
@@ -105,8 +105,9 @@ export function NodeTablePanel({ node }: { node: Node }) {
|
|
|
105
105
|
<thead className="sticky top-0 bg-card">
|
|
106
106
|
<tr>
|
|
107
107
|
{table.data.columns.map((column) => (
|
|
108
|
-
// Column names are distinct
|
|
109
|
-
//
|
|
108
|
+
// Column names are distinct on every path that writes a table — `DefineTableInput`,
|
|
109
|
+
// `RedefineTableInput` and, since #322, the bundle import, which used to walk past
|
|
110
|
+
// both — so the name is the key and nothing has to fall back to a position.
|
|
110
111
|
<th
|
|
111
112
|
key={column}
|
|
112
113
|
scope="col"
|
|
@@ -357,12 +357,32 @@ export function ResourceMenu({
|
|
|
357
357
|
<p className="text-sm">{i18n.t("flows.validateReady")}</p>
|
|
358
358
|
) : (
|
|
359
359
|
<ul className="space-y-2">
|
|
360
|
-
{
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
360
|
+
{/* ⚠️ Grouped by code, one entry per reason with every detail under it (#324).
|
|
361
|
+
`validateFlow` files one problem per sub-flow node, so a flow calling two
|
|
362
|
+
unpublished flows — the NORMAL case of that error, not its edge — produced two
|
|
363
|
+
entries whose head sentence is the same line of text, one under the other, and
|
|
364
|
+
two React children under one key.
|
|
365
|
+
|
|
366
|
+
Grouping rather than making the keys unique, because the head sentence is per CODE
|
|
367
|
+
(`flows.problem.<code>` is one string) and repeating it under itself says nothing
|
|
368
|
+
the reader did not just read. What differs is the detail, and every detail is
|
|
369
|
+
still here — which is the promise the comment above makes: somebody with two
|
|
370
|
+
missing tools should not have to ask twice. */}
|
|
371
|
+
{[
|
|
372
|
+
...validation.problems
|
|
373
|
+
.reduce((byCode, problem) => {
|
|
374
|
+
byCode.set(problem.code, [...(byCode.get(problem.code) ?? []), problem.detail]);
|
|
375
|
+
return byCode;
|
|
376
|
+
}, new Map<string, string[]>())
|
|
377
|
+
.entries(),
|
|
378
|
+
].map(([code, details]) => (
|
|
379
|
+
<li key={code} className="rounded-md border p-3 text-sm">
|
|
380
|
+
<span className="block font-medium">{i18n.t(`flows.problem.${code}`)}</span>
|
|
381
|
+
{details.map((detail) => (
|
|
382
|
+
<span key={detail} className="mt-1 block text-xs text-muted-foreground">
|
|
383
|
+
{detail}
|
|
384
|
+
</span>
|
|
385
|
+
))}
|
|
366
386
|
</li>
|
|
367
387
|
))}
|
|
368
388
|
</ul>
|