# 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`.