@anchrd/intel-ui 0.36.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/app/view-toggle/view-toggle.tsx +7 -2
- 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/flows/flows.tsx +11 -1
- package/src/frontmatter/frontmatter-markdown.ts +63 -0
- package/src/frontmatter/frontmatter.tsx +184 -0
- package/src/i18n/de.json +389 -363
- package/src/i18n/en.json +389 -363
- package/src/i18n/es.json +389 -363
- package/src/kind-icon.ts +17 -1
- package/src/node-editor/node-editor.tsx +37 -29
- package/src/nodes/nodes.tsx +3 -0
- package/src/resource-menu/resource-menu.tsx +12 -38
- package/src/title-row/title-row.tsx +12 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { BoardColumn, BoardTaskFilter } from "@anchrd/intel-contract/board";
|
|
2
|
+
import { isFiltering } from "@/board/board-data/board-data.ts";
|
|
3
|
+
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
4
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
|
+
import { useUserName } from "@/user-name/user-name.ts";
|
|
6
|
+
import {
|
|
7
|
+
ariaSortOf,
|
|
8
|
+
type GroupKey,
|
|
9
|
+
groupRows,
|
|
10
|
+
nextSort,
|
|
11
|
+
type Row,
|
|
12
|
+
rowsOf,
|
|
13
|
+
type Sort,
|
|
14
|
+
type SortKey,
|
|
15
|
+
sortRows,
|
|
16
|
+
} from "./board-table.ts";
|
|
17
|
+
|
|
18
|
+
const headers: { key: SortKey; labelKey: string }[] = [
|
|
19
|
+
{ key: "title", labelKey: "board.column.title" },
|
|
20
|
+
{ key: "status", labelKey: "board.column.status" },
|
|
21
|
+
{ key: "assignee", labelKey: "board.column.assignee" },
|
|
22
|
+
{ key: "due", labelKey: "board.column.due" },
|
|
23
|
+
// ⚠️ `dependsOn` is a column of its own, and that is the condition under which the board graph
|
|
24
|
+
// stays parked (#651, #653). Stored but undrawn, a dependency is a card that waits for a reason
|
|
25
|
+
// nobody can see.
|
|
26
|
+
{ key: "dependsOn", labelKey: "board.column.dependsOn" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function BoardTable({
|
|
30
|
+
board,
|
|
31
|
+
filter,
|
|
32
|
+
onFilter,
|
|
33
|
+
sort,
|
|
34
|
+
onSort,
|
|
35
|
+
group,
|
|
36
|
+
onGroup,
|
|
37
|
+
}: {
|
|
38
|
+
board: BoardHandle;
|
|
39
|
+
filter: Partial<BoardTaskFilter>;
|
|
40
|
+
onFilter(next: Partial<BoardTaskFilter>): void;
|
|
41
|
+
sort: Sort | null;
|
|
42
|
+
onSort(next: Sort | null): void;
|
|
43
|
+
group: GroupKey;
|
|
44
|
+
onGroup(next: GroupKey): void;
|
|
45
|
+
}) {
|
|
46
|
+
const i18n = useI18n();
|
|
47
|
+
|
|
48
|
+
if (board.isPending) {
|
|
49
|
+
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
50
|
+
}
|
|
51
|
+
if (board.isError) {
|
|
52
|
+
return (
|
|
53
|
+
<p role="alert" className="p-6 text-sm text-destructive">
|
|
54
|
+
{i18n.t("board.failed")}
|
|
55
|
+
</p>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const groups = groupRows(sortRows(rowsOf(board.columns), sort), group);
|
|
60
|
+
const configured: BoardColumn[] = board.columns
|
|
61
|
+
.filter((entry) => entry.unknown !== true)
|
|
62
|
+
.map((entry) => entry.column);
|
|
63
|
+
// ⚠️ The answer this is measured against is the FILTERED one. A board full of cards, narrowed to
|
|
64
|
+
// a column that happens to be empty, would otherwise be told it has no cards at all — and the
|
|
65
|
+
// reader would go looking for the cards they just watched disappear.
|
|
66
|
+
const empty = board.columns.every((entry) => entry.cards.length === 0);
|
|
67
|
+
const emptyMessage = isFiltering(filter) ? "board.filterEmpty" : "board.tableEmpty";
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-busy={board.isWriting}>
|
|
71
|
+
<div className="flex flex-wrap items-center gap-4 text-sm">
|
|
72
|
+
<label className="flex items-center gap-2">
|
|
73
|
+
{i18n.t("board.filter.status")}
|
|
74
|
+
{/* ⚠️ This one TRAVELS. `useBoard` carries the filter in the query key and sends it, so
|
|
75
|
+
the server answers with the cards that match. A view that fetched everything and hid
|
|
76
|
+
rows in the browser would look identical on screen and be a different thing over the
|
|
77
|
+
wire — which is why the test asserts the argument of the second request. */}
|
|
78
|
+
<select
|
|
79
|
+
className="rounded-md border bg-background px-2 py-1"
|
|
80
|
+
value={filter.status ?? ""}
|
|
81
|
+
onChange={(event) =>
|
|
82
|
+
onFilter(
|
|
83
|
+
event.target.value === ""
|
|
84
|
+
? { ...filter, status: undefined }
|
|
85
|
+
: { ...filter, status: event.target.value },
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
<option value="">{i18n.t("board.filter.all")}</option>
|
|
90
|
+
{configured.map((column) => (
|
|
91
|
+
<option key={column.id} value={column.id}>
|
|
92
|
+
{column.title}
|
|
93
|
+
</option>
|
|
94
|
+
))}
|
|
95
|
+
</select>
|
|
96
|
+
</label>
|
|
97
|
+
<label className="flex items-center gap-2">
|
|
98
|
+
{i18n.t("board.groupBy")}
|
|
99
|
+
<select
|
|
100
|
+
className="rounded-md border bg-background px-2 py-1"
|
|
101
|
+
value={group}
|
|
102
|
+
onChange={(event) => onGroup(event.target.value as GroupKey)}
|
|
103
|
+
>
|
|
104
|
+
<option value="none">{i18n.t("board.group.none")}</option>
|
|
105
|
+
<option value="status">{i18n.t("board.group.status")}</option>
|
|
106
|
+
<option value="assignee">{i18n.t("board.group.assignee")}</option>
|
|
107
|
+
</select>
|
|
108
|
+
</label>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
112
|
+
<table className="w-full border-collapse text-left text-sm">
|
|
113
|
+
<thead>
|
|
114
|
+
<tr className="border-b">
|
|
115
|
+
{headers.map((header) => (
|
|
116
|
+
<th
|
|
117
|
+
key={header.key}
|
|
118
|
+
scope="col"
|
|
119
|
+
aria-sort={ariaSortOf(sort, header.key)}
|
|
120
|
+
className="p-2 font-medium"
|
|
121
|
+
>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => onSort(nextSort(sort, header.key))}
|
|
125
|
+
className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
126
|
+
>
|
|
127
|
+
{i18n.t(header.labelKey)}
|
|
128
|
+
<span aria-hidden="true" className="text-xs text-muted-foreground">
|
|
129
|
+
{sort?.key === header.key ? (sort.direction === "asc" ? "▲" : "▼") : ""}
|
|
130
|
+
</span>
|
|
131
|
+
</button>
|
|
132
|
+
</th>
|
|
133
|
+
))}
|
|
134
|
+
<th scope="col" className="p-2 font-medium">
|
|
135
|
+
{i18n.t("board.column.labels")}
|
|
136
|
+
</th>
|
|
137
|
+
</tr>
|
|
138
|
+
</thead>
|
|
139
|
+
{groups.map((entry) => (
|
|
140
|
+
<tbody key={entry.key ?? "-none-"}>
|
|
141
|
+
{group === "none" ? null : (
|
|
142
|
+
<tr className="border-b bg-muted/40">
|
|
143
|
+
<th scope="rowgroup" colSpan={headers.length + 1} className="p-2 text-left">
|
|
144
|
+
{group === "assignee" ? (
|
|
145
|
+
<Assignee id={entry.key} />
|
|
146
|
+
) : (
|
|
147
|
+
(entry.key ?? i18n.t("board.unassigned"))
|
|
148
|
+
)}
|
|
149
|
+
<span className="ml-2 text-xs font-normal text-muted-foreground tabular-nums">
|
|
150
|
+
{entry.rows.length}
|
|
151
|
+
</span>
|
|
152
|
+
</th>
|
|
153
|
+
</tr>
|
|
154
|
+
)}
|
|
155
|
+
{entry.rows.map((row) => (
|
|
156
|
+
<Line key={row.task.id} row={row} />
|
|
157
|
+
))}
|
|
158
|
+
</tbody>
|
|
159
|
+
))}
|
|
160
|
+
</table>
|
|
161
|
+
{empty ? <p className="p-6 text-sm text-muted-foreground">{i18n.t(emptyMessage)}</p> : null}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Who a card belongs to, by the one rule this application has for saying so.
|
|
169
|
+
*
|
|
170
|
+
* ⚠️ **Never the raw id.** Intel has no user directory — `useUserName` can resolve exactly one id,
|
|
171
|
+
* the signed-in person's — and an id under the heading "assigned to" is not an answer to "who", it
|
|
172
|
+
* is the question again in smaller type. It would also put a Gate principal identifier on a screen
|
|
173
|
+
* that has no reason to carry one (`user-name.ts`, #258). Somebody this browser cannot name is
|
|
174
|
+
* drawn as somebody, which is true, and the word changes in exactly one place when Intel gains a
|
|
175
|
+
* lookup (#261).
|
|
176
|
+
*/
|
|
177
|
+
function Assignee({ id }: { id: string | null }) {
|
|
178
|
+
const i18n = useI18n();
|
|
179
|
+
const name = useUserName(id);
|
|
180
|
+
if (id === null) return <>{i18n.t("board.unassigned")}</>;
|
|
181
|
+
return <>{name ?? i18n.t("node.someUser")}</>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function Line({ row }: { row: Row }) {
|
|
185
|
+
return (
|
|
186
|
+
<tr className="border-b last:border-0">
|
|
187
|
+
<td className="p-2">{row.task.title}</td>
|
|
188
|
+
<td className="p-2">{row.columnTitle}</td>
|
|
189
|
+
<td className="p-2 text-muted-foreground">
|
|
190
|
+
<Assignee id={row.task.assigneeId} />
|
|
191
|
+
</td>
|
|
192
|
+
{/* The date, not the time: a board deals in days, and a timestamp in a cell is noise that
|
|
193
|
+
pushes the columns beside it off the screen. */}
|
|
194
|
+
<td className="p-2 tabular-nums">{row.task.dueDate?.slice(0, 10) ?? ""}</td>
|
|
195
|
+
<td className="p-2">{row.blocking ?? ""}</td>
|
|
196
|
+
<td className="p-2 text-muted-foreground">{row.task.labels.join(", ")}</td>
|
|
197
|
+
</tr>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { type NamedOrCounted, ProblemDetails, SessionUser } from "@anchrd/intel-contract";
|
|
2
|
+
import {
|
|
3
|
+
BoardGetInput,
|
|
4
|
+
BoardTaskCreateInput,
|
|
5
|
+
BoardTaskUpdateInput,
|
|
6
|
+
BoardUpdateInput,
|
|
7
|
+
BoardView,
|
|
8
|
+
} from "@anchrd/intel-contract/board";
|
|
2
9
|
import { BundleImportResult } from "@anchrd/intel-contract/bundle";
|
|
3
10
|
import {
|
|
4
11
|
ArchiveFlowInput,
|
|
@@ -344,6 +351,49 @@ export function createIntelDataProvider(
|
|
|
344
351
|
async listNodeVersions(nodeId) {
|
|
345
352
|
return await request(`/nodes/${encodeURIComponent(nodeId)}/versions`, NodeVersionList);
|
|
346
353
|
},
|
|
354
|
+
async getBoard(input) {
|
|
355
|
+
const parsed = BoardGetInput.parse(input);
|
|
356
|
+
// ⚠️ Only the filters the caller actually named travel. Sending an empty `status` would ask
|
|
357
|
+
// the server for the column called "" rather than for every column — the same rule the flow
|
|
358
|
+
// list follows above, and for the same reason.
|
|
359
|
+
const params = new URLSearchParams();
|
|
360
|
+
if (parsed.status !== undefined) params.set("status", parsed.status);
|
|
361
|
+
if (parsed.assigneeId !== undefined) params.set("assigneeId", parsed.assigneeId);
|
|
362
|
+
if (parsed.dueBefore !== undefined) params.set("dueBefore", parsed.dueBefore);
|
|
363
|
+
if (parsed.dependsOn !== undefined) params.set("dependsOn", parsed.dependsOn);
|
|
364
|
+
if (parsed.includeArchived) params.set("includeArchived", "true");
|
|
365
|
+
const query = params.toString();
|
|
366
|
+
return await request(
|
|
367
|
+
`/boards/${encodeURIComponent(parsed.boardId)}${query === "" ? "" : `?${query}`}`,
|
|
368
|
+
BoardView,
|
|
369
|
+
);
|
|
370
|
+
},
|
|
371
|
+
async updateBoard(input) {
|
|
372
|
+
const parsed = BoardUpdateInput.parse(input);
|
|
373
|
+
return await request(`/boards/${encodeURIComponent(parsed.boardId)}`, BoardView, {
|
|
374
|
+
method: "PATCH",
|
|
375
|
+
body: JSON.stringify(parsed),
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
async createBoardTask(input) {
|
|
379
|
+
const parsed = BoardTaskCreateInput.parse(input);
|
|
380
|
+
return await request(`/boards/${encodeURIComponent(parsed.boardId)}/tasks`, BoardView, {
|
|
381
|
+
method: "POST",
|
|
382
|
+
body: JSON.stringify(parsed),
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
// ⚠️ The board id is in the path although the call identifies the card by its own id: the route
|
|
386
|
+
// mirrors the tool name segment for segment (`board_task_update`), and a card that could be
|
|
387
|
+
// addressed without its board would invite a caller to move one between boards — which the
|
|
388
|
+
// service refuses on purpose (#377).
|
|
389
|
+
async updateBoardTask(boardId, input) {
|
|
390
|
+
const parsed = BoardTaskUpdateInput.parse(input);
|
|
391
|
+
return await request(
|
|
392
|
+
`/boards/${encodeURIComponent(boardId)}/tasks/${encodeURIComponent(parsed.taskId)}`,
|
|
393
|
+
BoardView,
|
|
394
|
+
{ method: "PATCH", body: JSON.stringify(parsed) },
|
|
395
|
+
);
|
|
396
|
+
},
|
|
347
397
|
async updateNode(input) {
|
|
348
398
|
const parsed = UpdateNodeInput.parse(input);
|
|
349
399
|
return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, Node, {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { SessionUser } from "@anchrd/intel-contract";
|
|
2
|
+
import type {
|
|
3
|
+
BoardGetInput,
|
|
4
|
+
BoardTaskCreateInput,
|
|
5
|
+
BoardTaskUpdateInput,
|
|
6
|
+
BoardUpdateInput,
|
|
7
|
+
BoardView,
|
|
8
|
+
} from "@anchrd/intel-contract/board";
|
|
2
9
|
import type { BundleImportResult } from "@anchrd/intel-contract/bundle";
|
|
3
10
|
import type {
|
|
4
11
|
ArchiveFlowInput,
|
|
@@ -95,6 +102,21 @@ export interface IntelDataProvider {
|
|
|
95
102
|
* would be reporting the wrong thing.
|
|
96
103
|
*/
|
|
97
104
|
reindexNodes(): Promise<ReindexResult>;
|
|
105
|
+
/**
|
|
106
|
+
* One board, in one answer: its columns and every card the caller may see, without the card
|
|
107
|
+
* bodies (#648).
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ **One call, not one per card.** A screen that fetched each card separately would look
|
|
110
|
+
* identical and is the reason this endpoint exists — the test that guards it counts requests
|
|
111
|
+
* rather than pixels.
|
|
112
|
+
*/
|
|
113
|
+
getBoard(input: BoardGetInput): Promise<BoardView>;
|
|
114
|
+
updateBoard(input: BoardUpdateInput): Promise<BoardView>;
|
|
115
|
+
createBoardTask(input: BoardTaskCreateInput): Promise<BoardView>;
|
|
116
|
+
// Every write answers with the WHOLE board rather than the row it changed: a drag moves one card
|
|
117
|
+
// and shifts nothing else, but a column's contents are what the screen draws, and re-deriving
|
|
118
|
+
// them from a single row is where a second, quietly different truth would start.
|
|
119
|
+
updateBoardTask(boardId: string, input: BoardTaskUpdateInput): Promise<BoardView>;
|
|
98
120
|
listNodes(input?: Partial<ListNodesInput>): Promise<NodeList>;
|
|
99
121
|
// One level of the shared tree: the documents and the flows filed in the same folder, in one
|
|
100
122
|
// sorted list. Per level rather than recursive, so opening a folder is what costs a request.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { DocumentLinkInlineType } from "@anchrd/intel-contract/node";
|
|
2
|
-
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
2
|
import { createReactInlineContentSpec } from "@blocknote/react";
|
|
4
3
|
import { FileText, Link2Off } from "lucide-react";
|
|
5
4
|
import { createContext, useContext } from "react";
|
|
@@ -87,23 +86,6 @@ export const documentLinkSpec = createReactInlineContentSpec(
|
|
|
87
86
|
},
|
|
88
87
|
);
|
|
89
88
|
|
|
90
|
-
// One schema for the editor and for everything that reads its documents back: BlockNote's defaults
|
|
91
|
-
// plus the one inline element #41 adds.
|
|
92
|
-
//
|
|
93
|
-
// ⚠️ `extend` rather than `create({ inlineContentSpecs: { ...defaults, documentLink } })`. Both
|
|
94
|
-
// build the same schema at run time, but only `extend` keeps the added type in the schema's own
|
|
95
|
-
// generics under this repo's `exactOptionalPropertyTypes` — with the spread form the editor infers
|
|
96
|
-
// BlockNote's default schema, typechecks, and then refuses to insert a document link.
|
|
97
|
-
export const intelEditorSchema = BlockNoteSchema.create().extend({
|
|
98
|
-
inlineContentSpecs: { documentLink: documentLinkSpec },
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// The schema carries its own editor and block types as declaration-only properties, which is the
|
|
102
|
-
// only way to name them here: writing `BlockNoteEditor<typeof schema.blockSchema, ...>` by hand
|
|
103
|
-
// fails BlockNote's own constraint under `exactOptionalPropertyTypes`.
|
|
104
|
-
export type IntelEditor = (typeof intelEditorSchema)["BlockNoteEditor"];
|
|
105
|
-
export type IntelEditorPartialBlock = (typeof intelEditorSchema)["PartialBlock"];
|
|
106
|
-
|
|
107
89
|
/**
|
|
108
90
|
* The IDs a stored BlockNote document links to.
|
|
109
91
|
*
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BlockNoteSchema } from "@blocknote/core";
|
|
2
|
+
import { documentLinkSpec } from "@/document-link/document-link.tsx";
|
|
3
|
+
import { frontmatterSpec } from "@/frontmatter/frontmatter.tsx";
|
|
4
|
+
|
|
5
|
+
// One schema for the editor and for everything that reads its documents back: BlockNote's defaults
|
|
6
|
+
// plus what Intel adds — the inline element of #41 and the head block of #657.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ `extend` rather than `create({ inlineContentSpecs: { ...defaults, documentLink } })`. Both
|
|
9
|
+
// build the same schema at run time, but only `extend` keeps the added types in the schema's own
|
|
10
|
+
// generics under this repo's `exactOptionalPropertyTypes` — with the spread form the editor infers
|
|
11
|
+
// BlockNote's default schema, typechecks, and then refuses to insert a document link.
|
|
12
|
+
export const intelEditorSchema = BlockNoteSchema.create().extend({
|
|
13
|
+
inlineContentSpecs: { documentLink: documentLinkSpec },
|
|
14
|
+
blockSpecs: { frontmatter: frontmatterSpec() },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// The schema carries its own editor and block types as declaration-only properties, which is the
|
|
18
|
+
// only way to name them here: writing `BlockNoteEditor<typeof schema.blockSchema, ...>` by hand
|
|
19
|
+
// fails BlockNote's own constraint under `exactOptionalPropertyTypes`.
|
|
20
|
+
export type IntelEditor = (typeof intelEditorSchema)["BlockNoteEditor"];
|
|
21
|
+
export type IntelEditorPartialBlock = (typeof intelEditorSchema)["PartialBlock"];
|
package/src/flows/flows.tsx
CHANGED
|
@@ -675,6 +675,7 @@ function FlowsEditor() {
|
|
|
675
675
|
saving={save.isPending}
|
|
676
676
|
canMutate={canMutate}
|
|
677
677
|
incomplete={stepsMissingServer.map((node) => ({ label: node.data.node.label }))}
|
|
678
|
+
scrollingView={selection.view === "runs"}
|
|
678
679
|
onSave={() => save.mutate()}
|
|
679
680
|
onPublish={() => setPublishing(true)}
|
|
680
681
|
/>
|
|
@@ -943,6 +944,7 @@ function FlowTitle({
|
|
|
943
944
|
saving,
|
|
944
945
|
canMutate,
|
|
945
946
|
incomplete,
|
|
947
|
+
scrollingView,
|
|
946
948
|
onSave,
|
|
947
949
|
onPublish,
|
|
948
950
|
}: {
|
|
@@ -950,6 +952,9 @@ function FlowTitle({
|
|
|
950
952
|
dirty: boolean;
|
|
951
953
|
saving: boolean;
|
|
952
954
|
canMutate: boolean;
|
|
955
|
+
// Whether what stands below this line can scroll under it. The runs list can and earns its edge
|
|
956
|
+
// the usual way; the canvas and the graph pan instead, and would never get one (#634).
|
|
957
|
+
scrollingView: boolean;
|
|
953
958
|
// Tool steps that name no server yet. They cannot be saved, so the refusal is shown here rather
|
|
954
959
|
// than fetched from the server as a message that names no step.
|
|
955
960
|
incomplete: Array<{ label: string }>;
|
|
@@ -974,7 +979,12 @@ function FlowTitle({
|
|
|
974
979
|
// loudest thing in this line and used to hold the edge; the menu takes it, because a menu one can
|
|
975
980
|
// hit without looking is worth more than the primary button being outermost. `TitleRow` is what
|
|
976
981
|
// enforces that — nothing passed in here can get past the menu.
|
|
977
|
-
<TitleRow
|
|
982
|
+
<TitleRow
|
|
983
|
+
title={flow.title}
|
|
984
|
+
description={flow.description}
|
|
985
|
+
target={{ type: "flow", flow }}
|
|
986
|
+
separated={!scrollingView}
|
|
987
|
+
>
|
|
978
988
|
{/* ⚠️ #454: the toggle used to stand in the global header, beside the search — where what
|
|
979
989
|
applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
|
|
980
990
|
that names this flow. A flow is the only level with runs, and therefore the only one with
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { IntelEditor, IntelEditorPartialBlock } from "@/editor-schema/editor-schema.ts";
|
|
2
|
+
import { FrontmatterBlockType, splitFrontmatter } from "@/frontmatter/frontmatter.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two halves of #657, and they only work as a pair.
|
|
6
|
+
*
|
|
7
|
+
* Before them the editor had no idea what a head was, and remark had no reason to invent one: it
|
|
8
|
+
* read `---` as a thematic break, the keys as a paragraph, and the LAST key plus the closing `---`
|
|
9
|
+
* as a setext heading — so `## autor: Jack` stood above the document's own title. That was the
|
|
10
|
+
* visible half.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ The invisible half is the one that cost data. `blocksToMarkdownLossy` is what the editor
|
|
13
|
+
* writes into `payload.markdown`, and that field is not decoration: `indexing.ts` pulls it — and
|
|
14
|
+
* nothing else — into full text and vectors, so it is the whole of what a search can match.
|
|
15
|
+
* Serialising those blocks back gave `***`, `\`-hard-breaks and `## autor: Jack`, which is no
|
|
16
|
+
* longer a head at all. Opening a document and saving it left its metadata unfindable, and nothing
|
|
17
|
+
* anywhere went red.
|
|
18
|
+
*
|
|
19
|
+
* Therefore: whatever is parsed out here is what is written back out there, byte for byte. The two
|
|
20
|
+
* functions are each other's inverse over the head, and the test that matters asserts exactly that.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Markdown to blocks, with the head lifted out of remark's way.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ Synchronous, because `tryParseMarkdownToBlocks` is. Making it `async` would put the load path
|
|
27
|
+
* behind a tick, and the effect in `node-editor.tsx` moves `savedSnapshot` immediately after it —
|
|
28
|
+
* a gap there is a document that reports itself unsaved the moment it opens (#115).
|
|
29
|
+
*/
|
|
30
|
+
export function markdownToBlocks(editor: IntelEditor, markdown: string): IntelEditorPartialBlock[] {
|
|
31
|
+
const { raw, body } = splitFrontmatter(markdown);
|
|
32
|
+
const blocks = editor.tryParseMarkdownToBlocks(body) as IntelEditorPartialBlock[];
|
|
33
|
+
if (raw === null) return blocks;
|
|
34
|
+
return [{ type: FrontmatterBlockType, props: { raw } } as IntelEditorPartialBlock, ...blocks];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Blocks to markdown, with the head written back as a head.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ The head is emitted from `raw`, never re-serialised from anything parsed. A head that is not
|
|
41
|
+
* valid YAML comes back exactly as it went in, and the roundtrip has no gap for a parser to fall
|
|
42
|
+
* into.
|
|
43
|
+
*/
|
|
44
|
+
export async function blocksToMarkdown(editor: IntelEditor, blocks: unknown[]): Promise<string> {
|
|
45
|
+
const all = blocks as { type?: string; props?: { raw?: unknown } }[];
|
|
46
|
+
// ⚠️ Found by type, NOT taken from position 0. The head is a block like any other: BlockNote's
|
|
47
|
+
// drag handle moves it, and typing above the first block pushes it down. A positional guard falls
|
|
48
|
+
// through to `blocksToMarkdownLossy` for exactly those documents, and that path has no head in it
|
|
49
|
+
// — it serialises the block's rendered DOM, so `payload.markdown` would carry the word "Metadata"
|
|
50
|
+
// where the metadata used to be. That is the same silent loss #657 is about, one gesture away.
|
|
51
|
+
const at = all.findIndex((block) => block?.type === FrontmatterBlockType);
|
|
52
|
+
if (at < 0) return editor.blocksToMarkdownLossy(blocks as never);
|
|
53
|
+
const head = all[at];
|
|
54
|
+
const raw = typeof head?.props?.raw === "string" ? head.props.raw : "";
|
|
55
|
+
// A document has ONE head, and it belongs at the top wherever the block ended up. Any further
|
|
56
|
+
// head block stays in the body and is serialised by the spec's own `toExternalHTML` — as a code
|
|
57
|
+
// block, which is visible and lossless without claiming to be a second head.
|
|
58
|
+
const body = await editor.blocksToMarkdownLossy(all.filter((_, index) => index !== at) as never);
|
|
59
|
+
// ⚠️ The blank line after the closing fence is part of the contract, not tidiness: without it the
|
|
60
|
+
// first line of the body sits against the fence, and a `# Title` there would be read back as part
|
|
61
|
+
// of nothing in particular the next time round.
|
|
62
|
+
return `---\n${raw}\n---\n\n${body}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createReactBlockSpec } from "@blocknote/react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import { createContext, Fragment, useContext, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The head of a knowledge node, as one block (#657).
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ The type lives here rather than in the contract, unlike `documentLink`. The server reads
|
|
9
|
+
* document links out of the stored blocks and therefore has to know that word; nothing on the
|
|
10
|
+
* server side ever looks at this one. What the server reads of a head is the `markdown` field
|
|
11
|
+
* beside the blocks — which is exactly why the export half of this module matters more than the
|
|
12
|
+
* block does.
|
|
13
|
+
*/
|
|
14
|
+
export const FrontmatterBlockType = "frontmatter";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The head, exactly as it stood between the two fences.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ Stored raw, never as parsed pairs, and this is the whole reason the roundtrip cannot lose
|
|
20
|
+
* anything. A head that is not valid YAML — a typo, a half-written list, a value somebody is in the
|
|
21
|
+
* middle of writing — travels through unchanged and comes back out as it went in. Parsing happens
|
|
22
|
+
* for the DISPLAY only, in `frontmatterEntries`, and a line that will not split is shown as it
|
|
23
|
+
* stands rather than dropped. The alternative, parsing on the way in and re-emitting on the way
|
|
24
|
+
* out, turns every gap in the parser into silent data loss at the next save.
|
|
25
|
+
*/
|
|
26
|
+
const FRONTMATTER_PROPS = { raw: { default: "" } } as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A head at the very start of the document, and only there.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ No `m` flag, and the `^` is doing real work — though not on the documents one first reaches
|
|
32
|
+
* for. `exec` without `g` finds a head at position 0 with or without the anchor; what the anchor
|
|
33
|
+
* decides is a document that has NO head and two rules further down. Unanchored, the first rule and
|
|
34
|
+
* the text after it are swallowed into an invented head. That is the case its test uses.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ `(?![ \t]*\r?\n)` — a head begins on the line after the fence, never with a blank one. This
|
|
37
|
+
* is what keeps `---\n\nErster Teil\n\n---` from reading as a head: nothing is lost from
|
|
38
|
+
* `markdown` when it does, but the rule, the text and the second rule all vanish behind a folded
|
|
39
|
+
* "Metadata" strip, and the reader has no way to know where their paragraph went.
|
|
40
|
+
*
|
|
41
|
+
* `[\s\S]+?` rather than `*?` says the same thing the falsy check in `splitFrontmatter` already
|
|
42
|
+
* says — an empty capture is no head — and is kept for saying it here rather than two lines later.
|
|
43
|
+
* It is not what makes `---\n---` two thematic breaks; that check is.
|
|
44
|
+
*
|
|
45
|
+
* The blank lines after the closing fence belong to the fence, not to the body. Leaving them on
|
|
46
|
+
* would make `body` start with an empty line that the head had put there — and the join in
|
|
47
|
+
* `blocksToMarkdown` writes exactly one back, so taking them here is what keeps a document from
|
|
48
|
+
* growing a blank line per save.
|
|
49
|
+
*/
|
|
50
|
+
const LEADING_FRONTMATTER =
|
|
51
|
+
/^---\r?\n(?![ \t]*\r?\n)([\s\S]+?)\r?\n---[ \t]*(?:\r?\n|$)(?:[ \t]*\r?\n)*/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A document's markdown, cut into its head and the rest.
|
|
55
|
+
*
|
|
56
|
+
* `raw` is `null` when there is no head, and then `body` is the whole input — the caller has one
|
|
57
|
+
* branch, not two shapes to tell apart.
|
|
58
|
+
*/
|
|
59
|
+
export function splitFrontmatter(markdown: string): { raw: string | null; body: string } {
|
|
60
|
+
const match = LEADING_FRONTMATTER.exec(markdown);
|
|
61
|
+
if (!match?.[1]) return { raw: null, body: markdown };
|
|
62
|
+
return { raw: match[1], body: markdown.slice(match[0].length) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The head split into what a reader sees, for display only.
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ It is never the source of the stored value — `raw` is. A line without a colon is not a broken
|
|
69
|
+
* pair but a normal part of YAML (a list item under the key above it, a folded value's second
|
|
70
|
+
* line), and it is shown whole rather than split at nothing.
|
|
71
|
+
*/
|
|
72
|
+
export function frontmatterEntries(raw: string): { key: string; value: string }[] {
|
|
73
|
+
return raw
|
|
74
|
+
.split(/\r?\n/)
|
|
75
|
+
.filter((line) => line.trim().length > 0)
|
|
76
|
+
.map((line) => {
|
|
77
|
+
const colon = line.indexOf(":");
|
|
78
|
+
const continues = /^[\s-]/.test(line);
|
|
79
|
+
if (colon < 0 || continues) return { key: "", value: line.trim() };
|
|
80
|
+
return { key: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim() };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The two words the block says. They travel in a context for the same reason the document link's
|
|
86
|
+
* one word does: a block is rendered by the editor, not by a view that could reach the catalog.
|
|
87
|
+
*/
|
|
88
|
+
export interface FrontmatterLabels {
|
|
89
|
+
summary: string;
|
|
90
|
+
expand: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const FrontmatterContext = createContext<FrontmatterLabels>({
|
|
94
|
+
summary: "Metadata",
|
|
95
|
+
expand: "Show metadata",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const FrontmatterProvider = FrontmatterContext.Provider;
|
|
99
|
+
|
|
100
|
+
export function FrontmatterHead({ raw }: { raw: string }) {
|
|
101
|
+
const labels = useContext(FrontmatterContext);
|
|
102
|
+
// ⚠️ Local state, deliberately NOT a block prop. The fold is how the head is being looked at, not
|
|
103
|
+
// what it says — and a prop would put it into the stored document, which means opening the head
|
|
104
|
+
// to read it would mark the document unsaved and offer to write a "change" nobody made.
|
|
105
|
+
const [open, setOpen] = useState(false);
|
|
106
|
+
const entries = frontmatterEntries(raw);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
// The editor owns the text around this; the head itself is not typed into.
|
|
111
|
+
contentEditable={false}
|
|
112
|
+
data-frontmatter={open ? "open" : "closed"}
|
|
113
|
+
className="my-2 w-full rounded-md border border-border bg-muted/40"
|
|
114
|
+
>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
aria-expanded={open}
|
|
118
|
+
aria-label={labels.expand}
|
|
119
|
+
onClick={() => setOpen(!open)}
|
|
120
|
+
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
121
|
+
>
|
|
122
|
+
<ChevronRight
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
className={
|
|
125
|
+
open ? "size-3.5 rotate-90 transition-transform" : "size-3.5 transition-transform"
|
|
126
|
+
}
|
|
127
|
+
/>
|
|
128
|
+
{/* ⚠️ Closed, it says THAT there is a head, never what is in it. A preview of `title` or
|
|
129
|
+
`status` here would be the H2 from #657 again, one size smaller. */}
|
|
130
|
+
{labels.summary}
|
|
131
|
+
</button>
|
|
132
|
+
{open ? (
|
|
133
|
+
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 px-3 pb-3 font-mono text-xs">
|
|
134
|
+
{entries.map((entry, index) =>
|
|
135
|
+
entry.key ? (
|
|
136
|
+
// Nothing in a head is unique — two `- Q-04` lines under `quellen` are the ordinary
|
|
137
|
+
// case — so the position is the only honest key. It is also a safe one here: the list
|
|
138
|
+
// is derived from an immutable `raw` and is never sorted, inserted into or filtered.
|
|
139
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a head's lines have no other key
|
|
140
|
+
<Fragment key={index}>
|
|
141
|
+
<dt className="text-muted-foreground">{entry.key}</dt>
|
|
142
|
+
<dd className="break-words text-foreground">{entry.value}</dd>
|
|
143
|
+
</Fragment>
|
|
144
|
+
) : (
|
|
145
|
+
// A line that is not a pair — a list item, a folded value's second line — spans both
|
|
146
|
+
// columns rather than being squeezed into the value one, and is indented so it still
|
|
147
|
+
// reads as belonging to the key above it.
|
|
148
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a head's lines have no other key
|
|
149
|
+
<dd key={index} className="col-span-2 break-words pl-4 text-foreground">
|
|
150
|
+
{entry.value}
|
|
151
|
+
</dd>
|
|
152
|
+
),
|
|
153
|
+
)}
|
|
154
|
+
</dl>
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const frontmatterSpec = createReactBlockSpec(
|
|
161
|
+
{
|
|
162
|
+
type: FrontmatterBlockType,
|
|
163
|
+
propSchema: FRONTMATTER_PROPS,
|
|
164
|
+
// The head is not editable text. It is written by whoever writes the document's markdown, and a
|
|
165
|
+
// caret inside it would let a reader break `type:` without it looking like anything happened.
|
|
166
|
+
content: "none",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
render: (props) => <FrontmatterHead raw={String(props.block.props.raw)} />,
|
|
170
|
+
// ⚠️ The net under `blocksToMarkdown`, for every path that does not go through it. Without a
|
|
171
|
+
// serialiser BlockNote falls back to the RENDERED DOM, so a head would leave the editor as the
|
|
172
|
+
// word "Metadata" — its own label, in place of the metadata. A fenced block is not a head, but
|
|
173
|
+
// it is visible and it is lossless, which is what a fallback owes.
|
|
174
|
+
//
|
|
175
|
+
// ⚠️ `<pre><code>`, not `<pre>` alone. Measured: a bare `<pre>` survives the HTML export and is
|
|
176
|
+
// then dropped by the HTML-to-markdown step, which returns `"\n"` — a net that looks like one
|
|
177
|
+
// in the HTML and catches nothing where it matters.
|
|
178
|
+
toExternalHTML: (props) => (
|
|
179
|
+
<pre>
|
|
180
|
+
<code>{String(props.block.props.raw)}</code>
|
|
181
|
+
</pre>
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
);
|