From 5bccc1e1210320ccd6a04ee34925802146c61335 Mon Sep 17 00:00:00 2001 From: Adorian Doran Date: Fri, 11 Sep 2026 17:45:29 +0300 Subject: [PATCH] client/collections/board: add outline integration --- apps/client/src/components/note_context.ts | 13 +++ .../src/translations/en/translation.json | 2 + .../src/widgets/collections/board/api.spec.ts | 41 +++++++ .../src/widgets/collections/board/api.ts | 22 +++- .../widgets/collections/board/index.spec.tsx | 100 +++++++++++++++++- .../src/widgets/collections/board/index.tsx | 38 +++++-- .../src/widgets/sidebar/BoardColumns.css | 30 ++++++ .../src/widgets/sidebar/BoardColumns.spec.tsx | 83 +++++++++++++++ .../src/widgets/sidebar/BoardColumns.tsx | 51 +++++++++ .../widgets/sidebar/RightPanelContainer.tsx | 8 ++ 10 files changed, 376 insertions(+), 12 deletions(-) create mode 100644 apps/client/src/widgets/sidebar/BoardColumns.css create mode 100644 apps/client/src/widgets/sidebar/BoardColumns.spec.tsx create mode 100644 apps/client/src/widgets/sidebar/BoardColumns.tsx diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index c63dd6f976..eade4b02ef 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -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; }; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 5797c81dd1..def6b34fc6 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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", diff --git a/apps/client/src/widgets/collections/board/api.spec.ts b/apps/client/src/widgets/collections/board/api.spec.ts index e6fb7c446e..e119d7ca8c 100644 --- a/apps/client/src/widgets/collections/board/api.spec.ts +++ b/apps/client/src/widgets/collections/board/api.spec.ts @@ -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 } + ]); + }); +}); diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 3f4181e719..dfcb7a8534 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -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(); } diff --git a/apps/client/src/widgets/collections/board/index.spec.tsx b/apps/client/src/widgets/collections/board/index.spec.tsx index 932c16d88f..757e9b99a1 100644 --- a/apps/client/src/widgets/collections/board/index.spec.tsx +++ b/apps/client/src/widgets/collections/board/index.spec.tsx @@ -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( + + + , + 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(".board-column")) { + element.scrollIntoView = () => scrolled.push(element.dataset.column); + const heading = element.querySelector("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"); + }); +}); diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 16042ebcc7..973019d2a0 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -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("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(".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(".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 ?? ""))) { diff --git a/apps/client/src/widgets/sidebar/BoardColumns.css b/apps/client/src/widgets/sidebar/BoardColumns.css new file mode 100644 index 0000000000..66188bcdac --- /dev/null +++ b/apps/client/src/widgets/sidebar/BoardColumns.css @@ -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; +} diff --git a/apps/client/src/widgets/sidebar/BoardColumns.spec.tsx b/apps/client/src/widgets/sidebar/BoardColumns.spec.tsx new file mode 100644 index 0000000000..092a76bf49 --- /dev/null +++ b/apps/client/src/widgets/sidebar/BoardColumns.spec.tsx @@ -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()), + 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(".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(, container)); +} diff --git a/apps/client/src/widgets/sidebar/BoardColumns.tsx b/apps/client/src/widgets/sidebar/BoardColumns.tsx new file mode 100644 index 0000000000..2b5648638c --- /dev/null +++ b/apps/client/src/widgets/sidebar/BoardColumns.tsx @@ -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 ( + +
+ {columns.length > 0 ? ( +
    + {columns.map(column => ( +
  1. + {/* The row is the scroll target, reachable by keyboard as the + other lists of the pane are. */} + data?.scrollToColumn(column.value)} + onKeyDown={e => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + data?.scrollToColumn(column.value); + } + }} + > + + {column.title} + + +
  2. + ))} +
+ ) : ( +
{t("board_view.columns-empty")}
+ )} +
+
+ ); +} diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index f4c1b16ce1..bab2cc28c2 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -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: , + enabled: (boardColumns?.columns.length ?? 0) > 0, + tab: "outline" + }, { el: , enabled: noteType === "llmChat" && (chatHighlights?.highlights.length ?? 0) > 0,