client/collections/board: add outline integration

This commit is contained in:
Adorian Doran 2026-09-11 17:45:29 +03:00
parent 3fba2c2088
commit 5bccc1e121
10 changed files with 376 additions and 12 deletions

View File

@ -71,6 +71,19 @@ export interface NoteContextDataMap {
annotations: PdfAnnotationInfo[];
scrollToAnnotation(annotationId: string, pageNumber: number): void;
};
/** Published by a board, so the right pane can list its columns and scroll the board to one. */
boardColumns: {
/** The columns in the order the board draws them. */
columns: {
/** What identifies the column, which is the value its cards carry. */
value: string;
title: string;
icon: string;
/** How many cards it holds, counting what an active filter leaves in. */
count: number;
}[];
scrollToColumn(column: string): void;
};
saveState: {
state: SaveState;
};

View File

@ -3530,6 +3530,8 @@
"sort": "Sort",
"sort-manually": "Manually",
"default-card-order": "Default card order",
"columns-title": "Columns",
"columns-empty": "This board has no columns yet.",
"column-width": "Column width",
"drop-as-first-item": "Drop as the first item",
"drop-as-last-item": "Drop as the last item",

View File

@ -2501,3 +2501,44 @@ describe("how wide the board draws its columns", () => {
expect(removeLabel).not.toHaveBeenCalled();
});
});
describe("the columns as the right pane lists them", () => {
it("names each column, gives it its icon and counts the cards it holds", () => {
const board = buildNote({ title: "Board" });
const cards = new Map([
[ "", [ { note: { noteId: "a" } }, { note: { noteId: "b" } } ] ],
[ "To Do", [ { note: { noteId: "c" } } ] ]
]) as unknown as ColumnMap;
const { api } = createApi(
{
columns: [
{ value: "", displayName: "Unsorted" },
{ value: "To Do", icon: "bx bx-star" },
{ value: "Done" }
]
},
[ "", "To Do", "Done" ], board, "status", cards);
expect(api.getColumnOutline([ "", "To Do", "Done" ])).toEqual([
{ value: "", title: "Unsorted", icon: "bx bxs-inbox", count: 2 },
{ value: "To Do", title: "To Do", icon: "bx bx-star", count: 1 },
// A column the board draws no cards for still stands, and counts none.
{ value: "Done", title: "Done", icon: DEFAULT_COLUMN_ICON, count: 0 }
]);
});
/** A relation board keys its columns by note id, which says nothing to a reader on its own. */
it("names a relation board's columns by the notes they point at", () => {
const target = buildNote({ title: "Alice", "#iconClass": "bx bx-user" });
const board = buildNote({ title: "Board" });
const { api } = createApi({}, [ "", target.noteId ], board, "~assignee");
expect(api.getColumnOutline([ "", target.noteId, "missing" ])).toEqual([
{ value: "", title: "board_view.inbox", icon: "bx bxs-inbox", count: 0 },
// The note's own icon, as `FNote` gives it, class and all.
{ value: target.noteId, title: "Alice", icon: "tn-icon bx bx-user", count: 0 },
// A note the cache has not got: the value is all there is to go on.
{ value: "missing", title: "missing", icon: DEFAULT_COLUMN_ICON, count: 0 }
]);
});
});

View File

@ -601,6 +601,24 @@ export default class BoardApi {
return false;
}
/**
* The columns as something outside the board lists them: what each is called, the icon it
* shows and how many cards it holds. The right pane's outline is drawn from this.
*
* A relation board keys its columns by note id and names them with the note's own title, which
* is what its headings show; the id says nothing to a reader on its own.
*/
getColumnOutline(columns: string[]) {
return columns.map(column => ({
value: column,
title: this.isRelationMode && column !== INBOX_COLUMN
? froca.getNoteFromCache(column)?.title ?? column
: this.getColumnTitle(column),
icon: this.getColumnIcon(column) ?? DEFAULT_COLUMN_ICON,
count: this.byColumn?.get(column)?.length ?? 0
}));
}
/**
* The icon a column heading shows, for anything else that stands in for the column.
*
@ -608,7 +626,9 @@ export default class BoardApi {
* `NoteLink` puts in the heading and `setColumnIcon` is not offered there.
*/
getColumnIcon(column: string) {
if (this.isRelationMode) {
// The inbox stands for the cards carrying no value at all, so there is no note behind it
// even on a relation board, where every other column is one.
if (this.isRelationMode && column !== INBOX_COLUMN) {
return froca.getNoteFromCache(column)?.getIcon();
}

View File

@ -211,6 +211,14 @@ function columnIcons(container: HTMLElement) {
.map(el => [ ...el.classList ].filter(name => name.startsWith("bx")).join(" "));
}
/**
* A stand-in note context, with the two calls every mounted board makes of one: it publishes its
* columns into the context for the right pane to list, and clears them as it goes.
*/
function contextStub(context: object) {
return { setContextData: () => {}, clearContextData: () => {}, ...context };
}
function columnTitles(container: HTMLElement) {
return [ ...container.querySelectorAll(".board-column h3 .title") ].map(el => el.textContent);
}
@ -795,7 +803,7 @@ describe("A board in a tab the reader is not looking at", () => {
const otherTab = {};
let shown: object = tab;
const host = new Component();
Object.assign(host, { noteContext: { getMainContext: () => tab } });
Object.assign(host, { noteContext: contextStub({ getMainContext: () => tab }) });
const previousTabManager = appContext.tabManager;
appContext.tabManager = { getActiveMainContext: () => shown } as never;
@ -862,7 +870,7 @@ describe("A board in a tab the reader is not looking at", () => {
// One tab, and the board is in a pane of it that does not hold the focus.
const tab = {};
const host = new Component();
Object.assign(host, { noteContext: { getMainContext: () => tab } });
Object.assign(host, { noteContext: contextStub({ getMainContext: () => tab }) });
const previousTabManager = appContext.tabManager;
appContext.tabManager = { getActiveMainContext: () => tab } as never;
@ -4234,7 +4242,7 @@ describe("Board properties from the note menu", () => {
});
const host = new Component();
Object.assign(host, { noteContext: { ntxId, isActive: () => true } });
Object.assign(host, { noteContext: contextStub({ ntxId, isActive: () => true }) });
const mountPoint = document.createElement("div");
container = mountPoint;
@ -4415,3 +4423,89 @@ describe("how wide the board draws its columns", () => {
return board;
}
});
describe("what the board hands the right pane", () => {
let container: HTMLElement | undefined;
afterEach(() => {
saved.length = 0;
if (container) {
render(null, container);
container.remove();
container = undefined;
}
});
/**
* The pane lists the columns of whichever board is on show, so the board publishes them into
* the context it belongs to rather than the pane reading the board's own state.
*/
it("publishes its columns, scrolls to one on request, and takes them back", async () => {
const published: { key: string, value: unknown }[] = [];
const cleared: string[] = [];
const note = buildNote({
title: "Board",
"#collection": "",
"#viewType": "board",
children: [
{ title: "First", "#status": "To Do" },
{ title: "Second", "#status": "Done" },
{ title: "Third", "#status": "Done" }
]
});
const host = new Component();
Object.assign(host, { noteContext: {
setContextData: (key: string, value: unknown) => published.push({ key, value }),
clearContextData: (key: string) => cleared.push(key)
} });
const mountPoint = document.createElement("div");
container = mountPoint;
document.body.appendChild(mountPoint);
await act(async () => {
render(
<ParentComponent.Provider value={host}>
<Harness
note={note}
noteIds={[ ...note.getChildNoteIds() ]}
initialConfig={{ columns: [ { value: "To Do" }, { value: "Done" } ] }}
/>
</ParentComponent.Provider>,
mountPoint
);
});
await act(async () => { await flush(); });
const outline = published.at(-1);
expect(outline?.key).toBe("boardColumns");
const { columns, scrollToColumn } = outline?.value as {
columns: { value: string, title: string, count: number }[],
scrollToColumn: (column: string) => void
};
expect(columns.map(({ title, count }) => `${title}:${count}`))
.toEqual([ "To Do:1", "Done:2" ]);
// What a press on one of the pane's entries does. Both are recorded rather than watched
// for: happy-dom scrolls nothing, and a dialog another spec left standing holds the focus.
const scrolled: (string | undefined)[] = [];
const focused: (string | undefined)[] = [];
for (const element of mountPoint.querySelectorAll<HTMLElement>(".board-column")) {
element.scrollIntoView = () => scrolled.push(element.dataset.column);
const heading = element.querySelector<HTMLElement>("h3");
if (heading) {
heading.focus = () => focused.push(element.dataset.column);
}
}
scrollToColumn("Done");
expect(scrolled).toEqual([ "Done" ]);
// The heading takes the focus with it, so the board's own keys carry on from there.
expect(focused).toEqual([ "Done" ]);
// A board that is no longer on show leaves nothing behind for the pane to list.
act(() => { render(null, mountPoint); });
expect(cleared).toContain("boardColumns");
});
});

View File

@ -32,7 +32,7 @@ import FormTextArea from "../../react/FormTextArea";
import FormTextBox from "../../react/FormTextBox";
import {
useContextualShortcutHints, useNoteContext, useNoteLabelBoolean, useNoteLabelWithDefault,
useNoteTypeOptions, useTrackedElement, useTriliumEvent
useNoteTypeOptions, useSetContextData, useTrackedElement, useTriliumEvent
} from "../../react/hooks";
import Icon from "../../react/Icon";
import NoteAutocomplete from "../../react/NoteAutocomplete";
@ -631,6 +631,24 @@ export default function BoardView({
/** Until when a column move can still be settling, which is when `useFlip` slides columns. */
const columnMovedUntil = useRef(0);
// What the right pane lists the board as, and what a press on one of its entries does.
//
// Held still between renders: the pane redraws its list whenever this changes identity, and a
// board renders on every step of a drag. `storedColumns` is among what it is held against
// because the api reads the config in place, so nothing else here changes when it does.
const outline = useMemo(() => ({
columns: api.getColumnOutline(shownColumns),
scrollToColumn: (column: string) => {
const element = columnElement(containerRef.current, column);
element?.scrollIntoView({ inline: "start", block: "nearest", behavior: "smooth" });
// The heading takes the focus, so the board's own keys carry on from the column the
// reader picked. Scrolled first, and without moving anything itself: focus landing on
// its own would jump the board to the column the scroll is already easing towards.
element?.querySelector<HTMLElement>("h3")?.focus({ preventScroll: true });
}
}), [ api, shownColumns, byColumn, storedColumns, isInRelationMode ]);
useSetContextData(noteContext, "boardColumns", outline);
// Neither the creation dates the tie-break needs nor the targets of a sorted relation come
// with the board. Both are fetched here, and `sortRevision` redraws it once they land.
useEffect(() => {
@ -970,13 +988,8 @@ export default function BoardView({
if (!isMobile()) return;
requestAnimationFrame(() => {
const columns = containerRef.current?.querySelectorAll<HTMLElement>(".board-column");
for (const element of columns ?? []) {
if (element.dataset.column === column) {
element.scrollIntoView({ inline: "center", block: "nearest" });
return;
}
}
columnElement(containerRef.current, column)
?.scrollIntoView({ inline: "center", block: "nearest" });
});
}, []);
@ -1415,6 +1428,15 @@ function closeGaps(container: HTMLElement | null) {
}
}
/** The element a column is drawn in, for the two things that scroll the board to one. */
function columnElement(container: HTMLElement | null, column: string) {
for (const element of container?.querySelectorAll<HTMLElement>(".board-column") ?? []) {
if (element.dataset.column === column) {
return element;
}
}
}
export function findRefreshReason(loadResults: LoadResults, statusAttribute: string, noteIds: string[], parentNoteId: string): string | null {
// A card moved between columns.
if (loadResults.getAttributeRows().some(attr => attr.name === statusAttribute && noteIds.includes(attr.noteId ?? ""))) {

View File

@ -0,0 +1,30 @@
.board-columns-list ol {
list-style: none;
margin: 0;
padding: 0;
}
.board-columns-list .board-column-entry {
display: flex;
align-items: center;
gap: 0.4em;
padding: 0.25em 0.4em;
border-radius: 4px;
cursor: pointer;
}
.board-columns-list .board-column-entry:hover {
background-color: var(--hover-item-background-color);
}
.board-columns-list .board-column-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-columns-list .no-columns {
padding: 0.4em;
opacity: 0.7;
}

View File

@ -0,0 +1,83 @@
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NoteContextDataMap } from "../../components/note_context";
import options from "../../services/options";
import BoardColumns from "./BoardColumns";
// The board publishes its columns through the note context, which needs the whole app around it,
// so the panel is handed them directly here.
const shown = vi.hoisted(() => ({
board: null as NoteContextDataMap["boardColumns"] | null
}));
vi.mock("../../services/i18n", () => ({ t: (key: string) => key }));
vi.mock("../react/hooks", async (importOriginal) => ({
...(await importOriginal<typeof import("../react/hooks")>()),
useGetContextData: () => shown.board
}));
let container: HTMLDivElement;
beforeEach(() => {
// The widget stands in a RightPanelWidget, which reads which panels the user collapsed.
options.set("rightPaneCollapsedItems", JSON.stringify([]));
shown.board = {
columns: [
{ value: "", title: "Inbox", icon: "bx bxs-inbox", count: 2 },
{ value: "To Do", title: "To Do", icon: "bx bx-circle", count: 7 },
{ value: "Done", title: "Done", icon: "bx bx-check", count: 0 }
],
scrollToColumn: vi.fn()
};
container = document.createElement("div");
document.body.append(container);
});
afterEach(() => {
act(() => render(null, container));
container.remove();
shown.board = null;
});
describe("BoardColumns", () => {
it("lists the columns in the board's own order, each with its icon and how many it holds", () => {
renderPanel();
const entries = [ ...container.querySelectorAll(".board-column-entry") ];
expect(entries.map((entry) => entry.querySelector(".board-column-name")?.textContent))
.toEqual([ "Inbox", "To Do", "Done" ]);
expect(entries.map((entry) => entry.querySelector(".badge")?.textContent))
.toEqual([ "2", "7", "0" ]);
expect(entries[1].querySelector("span.bx-circle")).toBeTruthy();
});
it("asks the board to scroll to the column that was pressed, by mouse or by keyboard", () => {
renderPanel();
const entries = [ ...container.querySelectorAll<HTMLElement>(".board-column-entry") ];
entries[2].click();
expect(shown.board?.scrollToColumn).toHaveBeenCalledWith("Done");
act(() => {
entries[0].dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
// The inbox is named by the empty value, which is what it is identified by throughout.
expect(shown.board?.scrollToColumn).toHaveBeenLastCalledWith("");
});
it("says so for a board with no columns at all", () => {
shown.board = { columns: [], scrollToColumn: vi.fn() };
renderPanel();
expect(container.querySelector(".board-column-entry")).toBeNull();
expect(container.querySelector(".no-columns")?.textContent)
.toBe("board_view.columns-empty");
});
});
function renderPanel() {
act(() => render(<BoardColumns />, container));
}

View File

@ -0,0 +1,51 @@
import "./BoardColumns.css";
import { t } from "../../services/i18n";
import SimpleBadge from "../react/Badge";
import { useGetContextData } from "../react/hooks";
import Icon from "../react/Icon";
import RightPanelWidget from "./RightPanelWidget";
/**
* The board's columns, in the order it draws them, so that one of them can be reached on a board
* too wide to see at once. The board publishes the list and the scrolling; this widget draws it.
*/
export default function BoardColumns() {
const data = useGetContextData("boardColumns");
const columns = data?.columns ?? [];
return (
<RightPanelWidget id="board-columns" title={t("board_view.columns-title")} grow>
<div className="board-columns-list">
{columns.length > 0 ? (
<ol>
{columns.map(column => (
<li key={column.value}>
{/* The row is the scroll target, reachable by keyboard as the
other lists of the pane are. */}
<span
className="board-column-entry"
role="button"
tabIndex={0}
onClick={() => data?.scrollToColumn(column.value)}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
data?.scrollToColumn(column.value);
}
}}
>
<Icon icon={column.icon} />
<span className="board-column-name">{column.title}</span>
<SimpleBadge title={column.count} />
</span>
</li>
))}
</ol>
) : (
<div className="no-columns">{t("board_view.columns-empty")}</div>
)}
</div>
</RightPanelWidget>
);
}

View File

@ -20,6 +20,7 @@ import { PaneMode, usePaneMode, usePeekDismiss } from "../react/peek_pane";
import LegacyRightPanelWidget from "../right_panel_widget";
import AttributeList from "./AttributeList";
import Backlinks from "./Backlinks";
import BoardColumns from "./BoardColumns";
import ChatHighlightsList from "./ChatHighlightsList";
import HighlightsList from "./HighlightsList";
import NoteMap from "./NoteMap";
@ -203,6 +204,8 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent):
// Published by the LLM chat; drives the chat highlights widget's visibility (only shown once
// the chat has at least one highlight).
const chatHighlights = useGetContextData("chatHighlights");
// Published by a board, which is what says the note is being shown as one.
const boardColumns = useGetContextData("boardColumns");
// Subscribe to the AI toggle so the LLM chat is added/removed reactively without a page reload.
const [ aiEnabled ] = useTriliumOptionBool("aiEnabled");
const isPdf = noteType === "file" && noteMime === "application/pdf";
@ -268,6 +271,11 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent):
enabled: (noteType === "text" || !!note?.isMarkdown()) && highlightsList.length > 0,
tab: "outline"
},
{
el: <BoardColumns />,
enabled: (boardColumns?.columns.length ?? 0) > 0,
tab: "outline"
},
{
el: <ChatHighlightsList />,
enabled: noteType === "llmChat" && (chatHighlights?.highlights.length ?? 0) > 0,