# LibrariesSidebar Knowledge Libraries sidebar: renders a tree of articles (root + nested subarticles), lets you navigate, create, and reorder via drag & drop. It's built for large lists (hundreds/thousands of nodes), so it has several performance decisions worth understanding before you touch it. --- ## TL;DR (mental model in 30 seconds) 1. The consumer (backoffice/web) fetches data from its API and turns it into a **`SidebarModel`** using the **adapters**. The component never talks to the API. 2. You pass the component `model` + `capabilities` + callbacks. That's it. 3. Inside, a single **`SidebarProvider`** (context) owns all the state (sort mode, pending order, which rows are expanded). Rows only read from the context. 4. The **root** list is virtualized (only renders what's visible). Drag & drop (`dnd-kit`) is only mounted when you enter **sort mode**. ``` consumer API ──► adapters ──► SidebarModel ──► ──► rows (UI) (each repo) (material-hu) (contract) (this component) ``` --- ## Key concepts ### `SidebarModel` — the contract This is the **only way** data enters the component. Defined in [`types/model.ts`](./types/model.ts): ```ts type SidebarModel = { headerTitle: string; // header title (empty at root) nodes: readonly SidebarNode[]; // FLAT list (adjacency list), not a tree }; ``` Key point: `nodes` is a **flat list**, not a tree. Each node knows its parent (`parentId`) and its depth (`depth`). The tree is rebuilt in memory inside the context (`childrenByParent`). ```ts type SidebarNode = { id: number; parentId: number | null; // null = lives at the root position: number; // order among siblings title: string; icon: IconInterface; status: 'enabled' | 'disabled'; childCount: number; // how many direct children (for the chevron) depth: number; // 1 = root, 2 = child, ... isEditor?: boolean | null; }; ``` ### `SidebarCapabilities` — the permissions/flags Presentation flags decided by the consumer (typically from user permissions): ```ts type SidebarCapabilities = { canCreate: boolean; // shows/hides the "+" buttons showStatusBadge: boolean; // shows the status badge on the avatar }; ``` ### `LibrariesTreeItem` — the row model It's the `SidebarNode` already "cooked" for the UI (node + capabilities merged). Produced by `toLibrariesTreeItem` in [`utils/index.ts`](./utils/index.ts). Rows work with this, **not** with `SidebarNode` directly. --- ## How to consume it (real example) The consumer does 3 things: (1) adapt data into a `SidebarModel`, (2) build `capabilities`, (3) render `` with the callbacks. ```tsx // 1) Adapt API → SidebarModel (in a consumer hook) const model = resolveSidebarModel(isRoot, rootArticles, childrenModel); // 2) Capabilities from permissions const capabilities = { canCreate: canCreateLibraries, showStatusBadge: true }; // 3) Render root view, number => children view loading={isLoading} onBack={handleBack} onItemClick={onItemClick} // navigate to the article onAdd={handleCreate} // open the create drawer onSort={handleSort} // persist the new order onAddMouseEnter={prefetch} // (optional) prefetch on hover /> ``` ### The adapters (your entry point as a consumer) They live in [`utils/adapters.ts`](./utils/adapters.ts). They turn the API response into a `SidebarModel`: | Function | Purpose | |---|---| | `adaptFromRootArticles(articles)` | Root list (`GET /knowledge-libraries`) | | `adaptFromChildrenTree(tree, viewParentId)` | Nested tree (`GET /:id/children-tree`) | | `resolveSidebarModel(isRoot, root, children)` | Picks root vs nested per the view | | `recalculateChildCounts(model)` | Recomputes `childCount` after filtering nodes | | `applySidebarNodeFilter(model, predicate, ctx)` | Filters nodes (e.g. search) + recomputes counts | > `onSort` hands you back `SortedItem[]`. Use `getNewPositionOrders(payload)` from > [`utils/index.ts`](./utils/index.ts) to convert it into the `{ parentId, orderedIds }` > shape the backend usually expects. --- ## How it works inside ### File structure ``` LibrariesSidebar/ ├── index.tsx # Entry point. Wires up Provider + header + list. ├── context.tsx # ⭐ SidebarProvider: owner of ALL state. ├── types/ │ ├── model.ts # Data contract (SidebarModel, SidebarNode, ...). │ └── index.ts # Internal component types (props, context, row). ├── utils/ │ ├── adapters.ts # API → SidebarModel (used by the consumer). │ ├── tree.ts # sortByPosition, flattenTree, buildDepthMap. │ └── index.ts # toLibrariesTreeItem, getReorderedItems, etc. ├── hooks/ │ ├── useSortableList.ts # Connects a list (root or children) to drag & drop. │ └── useGetSidebarColors.ts └── components/ ├── headers/ # Header, actions, sort buttons. └── list/ ├── SortableRows.tsx # dnd-kit wrapper (sort mode only). ├── root/ # Root list (virtualized). └── children/ # Nested lists (recursive accordion). ``` ### The context owns the state ([`context.tsx`](./context.tsx)) All the logic lives in `SidebarProvider`. Rows and headers only **read** from the context via `useSidebarContext()`. What it manages: - **Item index**: builds `itemsById` (O(1) lookup) and `childrenByParent` (rebuilt tree) from the flat `model.nodes` list. - **Sort mode**: the `isSortMode` toggle and whether the sort button shows (`showSortButton`, on when `onSort` is provided). - **Pending (optimistic) order**: while sorting, drag results are staged per parent in `pendingSortByParent`. On save, they're emitted via `onSort` and kept applied until the refetched `model` reflects them. - **Row expansion**: tracked in a `Set` inside a `ref` (`expandedIds`), so opening/closing a row does **not** re-render the tree. ### Root vs Children (two different lists) - **Root** ([`list/root/SidebarRootList.tsx`](./components/list/root/SidebarRootList.tsx)): flat, **virtualized** list (`useVirtualizer`, backed by `@tanstack/react-virtual`). Only renders visible rows → scales to thousands of items. - **Children** ([`list/children/SidebarChildrenList.tsx`](./components/list/children/SidebarChildrenList.tsx)): **recursive** accordion. Each `SidebarChildRow` can expand and render another `SidebarChildrenList` inside (``). Only the **top level (`depth === 0`)** is virtualized with `useVirtualizer`, using **dynamic measurement** (`rowVirtualizer.measureElement`) because rows grow when expanded. Nested levels (`depth > 0`) render inline so the parent's measured row reflects their full expanded height (no nested scroll containers). ### The sort mode flow 1. User taps "sort" → `handleToggleSortMode()` sets `isSortMode = true`. 2. The list switches from its normal render to `SortableRows` (which mounts `dnd-kit`). 3. Each drag calls `handleDragEnd` → `getReorderedItems` → `setPendingSort` (stages the optimistic order per parent). 4. "Save" → `handleSaveSort()` emits `onSort(sortedItems)` and exits sort mode. The optimistic order stays until the refetch confirms it. 5. "Cancel" → `handleRestoreSortableItems()` discards the pending order. --- ## Performance notes (why it's built this way) > If you're going to touch the list rendering, read this first. 1. **Root is virtualized in read mode.** Rendering thousands of MUI rows at once is expensive. `useVirtualizer` (`@tanstack/react-virtual`) only mounts what's visible, absolutely positioned inside a sized spacer. 2. **`dnd-kit` only in sort mode.** `dnd-kit` adds per-row cost (listeners, per-item context, re-renders on every move). That's why `SortableRows` is **only** mounted when `isSortMode` is true. In read mode rows are flat. - Expected side effect: **entering** sort mode on a large list has a small delay because all sortable items mount at once (the list is no longer virtualized). It's a one-time mounting cost and expected. 3. **Optimistic order keyed by content, not array identity.** The pending-order reset is triggered by a stable `id:position` signature of the nodes (`nodesSignature`), not by the `items` array identity. This keeps the optimistic order alive until the real refetch, regardless of whether the consumer memoizes its `model`/`capabilities`. 4. **Expansion in a `ref`, not state.** Opening/closing rows doesn't re-render the tree and the open/closed state survives remounts (important with virtualization). 5. **Memoized rows** (`SortableRootRow`, `SidebarChildRow` with `React.memo`): avoid re-renders when the context changes but the row doesn't. --- ## "I want to..." (quick recipes) - **...add a field to each row** → add it to `SidebarNode` ([`types/model.ts`](./types/model.ts)), propagate it in the adapters ([`utils/adapters.ts`](./utils/adapters.ts)) and in `toLibrariesTreeItem` ([`utils/index.ts`](./utils/index.ts)) if the UI needs it. - **...add a permission/flag** → add it to `SidebarCapabilities` ([`types/model.ts`](./types/model.ts)) and consume it where needed. - **...change how a row looks** → `SortableRootRow` (root) or `SidebarChildRow` (children), in `components/list/`. - **...change the reorder logic** → `getReorderedItems` ([`utils/index.ts`](./utils/index.ts)) and the sort flow in [`context.tsx`](./context.tsx). - **...filter nodes (e.g. search)** → use `applySidebarNodeFilter` in the consumer; don't put filtering inside the component. - **...support a new API** → write a new `adaptFrom...` in [`utils/adapters.ts`](./utils/adapters.ts) that returns a `SidebarModel`. Don't touch the lists. --- ## Golden rules - The component does **not** talk to the API. Everything comes in as a `SidebarModel`. - **State lives in the context**, not in rows. Rows read, they don't own. - `model.nodes` is **flat**. The tree is built in memory. - Don't put `dnd-kit` in the read-mode render. - If you touch `lib/` (artifacts): run the full `bun run tsc` before committing, or the push fails on the `prepush:tsc-guard`.