Skip to content

Add nested documents with hierarchical sidebar, drag-and-drop and breadcrumbs - #94

Merged
santhoshh-kumar merged 20 commits into
mainfrom
feat/sidebar-tree
Sep 1, 2026
Merged

Add nested documents with hierarchical sidebar, drag-and-drop and breadcrumbs#94
santhoshh-kumar merged 20 commits into
mainfrom
feat/sidebar-tree

Conversation

@santhoshh-kumar

Copy link
Copy Markdown
Collaborator

What this PR does

  • This PR adds Notion-style nesting to Nextdocs. Previously all documents were flat root pages with a simple Private/Shared list, so there was no way to organize pages hierarchically, reorder them persistently, or understand where you are in a deep structure.
  • Now users can create child documents under any page, expand and collapse them in the sidebar, reorder by drag-and-drop, and navigate via breadcrumbs in the top toolbar that collapse gracefully for deep hierarchies. Shared documents also respect personal ordering so collaborators can arrange their Shared section without affecting the owner, and trash, permissions and collaborator access all inherit correctly through the tree.

Overview of commits

  • The change is a full vertical slice across API and web. The backend introduces base62 fractional indexing for stable ordering, schema for parent links and personal shared orders, ancestor-based permission resolution, and a unified document query, tree move and breadcrumb path traversal.
  • The frontend consolidates the document client onto that unified endpoint, adds dedicated tree state for Private and Shared, and rebuilds the sidebar around reusable tree components with dnd-kit alongside a breadcrumb hook that merges local tree state with background fetching. The realtime test harness was also migrated to ESM as part of the branch.

Concerns

  • There is a slight flickering issue when reordering a document in the shared section when "show more" is present in private secion -- need to be fixed as a follow-up (not found the root-cause yet).
  • Also we need to create a feature to remember the sidebar state across reloads for polished UX. Currently the sidebar loads with same defined intial state and it makes it hard to expand the docs that are already expanded before reload once again.

…ing.

Ordering items in a database using sequential integers requires updating all
subsequent rows whenever an item is inserted or moved. To allow O(1) inserts and
moves at arbitrary positions without table-wide rewrites, we implement a Base62
fractional indexing utility that generates lexicographically sortable string keys.

The generator computes midpoint keys between any two valid bounds using a
variable-length integer prefix followed by fractional digits. In addition to
single-key generation, it provides batch generation helpers with configurable gaps
to distribute keys evenly when re-indexing densely packed sibling lists. Order
keys are validated against character set and length invariants to ensure correct
lexicographical sorting across database collations.
To support arbitrary document hierarchies, we extend the documents table with a
self-referencing parent_id foreign key and a sibling_order_key column. A check
constraint enforces that any nested document (parent_id IS NOT NULL) must have
a sibling_order_key, while root documents leave sibling_order_key null. A partial
index on (parent_id, sibling_order_key) optimizes child retrieval under any
parent node.

In the JPA model, Document is mapped with a lazy self-referencing parent relation
and siblingOrderKey property. DocumentRepository and DocumentCollaboratorRepository
are extended with query methods for querying direct non-trashed children,
retrieving sibling keys for boundary calculations, and cleaning up collaborator
records during document deletion.
In Nextdocs, root documents appear in two sidebar sections: Private (owned) and
Shared (collaborating). Collaborators must be able to organize shared documents
in their personal sidebar navigation without mutating the document entity or
affecting how other collaborators view the list.

We introduce the user_document_orders table to decouple personal sidebar ordering
from document content. Each row maps a user and document to an order_key. Flyway
migrations V8 and V9 re-index existing timestamp keys into valid Base62
fractional keys and enforce unique constraints on (user_id, order_key) and
(parent_id, sibling_order_key) so that concurrent reorders cannot produce duplicate
keys within a user's navigation or parent child list.

UserDocumentOrderRepositoryTest tests persistence, unique constraint enforcement,
and neighbor key lookups.
Previously, access checks inspected only the target document's direct owner or
collaborator rows. In a nested hierarchy, permissions granted on an ancestor page
must inherit down to all descendants using a closest-ancestor-wins rule.

We introduce the resolve_effective_access PostgreSQL recursive function, which
walks the parent chain up to 100 levels to find the nearest explicit grant. For
trash management, resolve_trash_access identifies the root of the contiguous
trashed subtree (the trash bundle) and resolves access against that root, ensuring
that items grafted into another user's tree follow the host tree's lifecycle.
Migration V11 normalizes nested document ownership so child.user_id always matches
the root owner (location authority).

PermissionService centralizes all authorization checks across the application,
providing strict methods for read, edit, direct ownership, and trash scope access.
PermissionServiceTest verifies inheritance rules, link access resolution, and
trash boundary enforcement.
Extends DocumentService to support creating documents at specific positions within
a parent's hierarchy or at the root level. When creating a nested document, the
service enforces EDIT permission on the target parent, adopts the parent's owner
under location authority, and calculates initial fractional index ordering keys.

Deleting a document cascades soft-deletion across all of its descendants, and
permanent purge removes the entire subtree along with associated collaborator and
ordering records. Restoring a trashed document restores its descendant subtree,
verifies that its parent is not trashed (or falls back to root level), and generates
a fresh ordering key if the original key collides with active siblings.
When a user is added as a collaborator to a document, they need an entry in
user_document_orders so the document appears in their Shared sidebar section.
DocumentSharingService is updated to create this navigation row prepended to the
user's list, with automatic retries if concurrent additions generate colliding
fractional keys. Removing a collaborator cleans up their navigation ordering row.

The service also integrates with PermissionService to evaluate access through
ancestor resolution. getMyAccess now returns pre-trash access information for
trashed documents, allowing the frontend to render a read-only trash preview
while keeping realtime websocket connections strictly restricted.
Implements DocumentTreeService and controller endpoints to power the sidebar tree
UI and handle drag-and-drop document movements.

Root documents in Private and Shared sections, as well as direct children of any
parent node, are fetched with batch child counts for expandable tree chevrons and
batch effective access resolution. The move operation handles reparenting to a new
parent node or reordering within root navigation. It prevents circular references
by walking the target ancestor chain up to 100 levels, transfers ownership of the
subtree to the host parent tree, and automatically re-indexes siblings or user
orders if key intervals are exhausted or colliding.
Following the migration to NodeNext module resolution in
cf2d001, the test suite remained
configured for CommonJS execution, causing Jest to fail when
importing ECMAScript modules with explicit file extensions.

We update jest.config.js and tsconfig.test.json to inherit the
NodeNext module settings and configure the test script with
NODE_OPTIONS=--experimental-vm-modules. Unit and integration tests
are updated to use jest.unstable_mockModule and top-level dynamic
imports for mocked modules, ensuring all mocks resolve correctly in a
native ESM runtime.

Lifecycle integration tests resolve the tsx CLI binary dynamically
from node_modules rather than depending on hardcoded path structures.
Previously, document retrieval was fragmented across distinct
endpoints for root trees, shared documents, child nodes, and flat
lists, requiring callers to handle divergent response schemas and
multiple roundtrips.

We introduce DocumentListQueryHelper to centralize all document
queries under GET /api/v1/documents with parentId, scope, and trashed
query parameters. DocumentResponse is enriched with hasChildren,
hasCollaborators, and effective accessLevel fields computed in
single-roundtrip batch database queries, eliminating N+1 lookups on
the client. DocumentTreeNodeResponse and redundant tree endpoints are
removed in favor of this single unified contract.

GlobalExceptionHandler is updated to map NoResourceFoundException to
standard 404 responses and suppress redundant default message
echoing.
Previously, classifying owned documents between private and shared
sections required dispatching concurrent listCollaborators HTTP
requests for every document on initial load, causing significant
latency and unnecessary API load.

With the backend providing hasCollaborators and parentId directly on
document responses, classifyOwnedDocuments is converted into a
synchronous in-memory filter. We update DocumentService to consume
the consolidated GET /api/v1/documents endpoint, add helpers for
tree node pagination and document moves, and define shared tree data
contracts in tree.types.ts.

In the editor toolbar, trash notice rendering is refined so only
users with EDIT permissions or ownership see the restore action,
while viewers and commenters receive an informative read-only banner.
Managing multi-level document trees requires tracking recursive
expansion states, lazy loading of child branches, and optimistic
position updates across distinct Private and Shared namespaces.

We implement the sidebarTree and sharedTree Redux slices to manage
tree node registries, lazy child fetching thunks, expansion toggles,
and base62 order key sorting. The sharedTree slice implements
syncSharedRoots to maintain proper parent-child relationships for
shared-with-me documents while guaranteeing that nested owned
documents remain strictly under their private parent hierarchy.

We introduce sidebar-drop-rules.ts to encapsulate pure validation
policy for tree drag operations, enforcing permissions boundaries
between private hierarchies and personal shared navigation orders.
Replaces the flat sidebar document list with interactive, nested
tree components supporting deep hierarchies, recursive expansion,
and drag-and-drop reordering.

We integrate @dnd-kit to provide accessible drag-and-drop mechanics
with visual insertion indicators and drop highlighting that match the
editor theme. The sidebar UI is decomposed into SidebarTree,
SharedTree, SidebarTreeItem, and SidebarSection components, featuring
inline child document creation, chevron toggles, and collaborator
badges.

DocumentsPanel and Sidebar are refactored to support tree navigation
within modal panels, search filtering, and state resets on logout.
When resizing the sidebar, adjacent fixed UI elements (such as top
breadcrumb navigation) need to know the active sidebar width to smoothly
offset their positions without layout jitter.

This commit persists the sidebar width in the Redux store and updates
the '--nd-sidebar-width' CSS variable in lockstep.
Deeply nested document structures require ancestor path resolution for
Notion-style breadcrumbs and navigation.

The breadcrumb path traversal climbs the document tree up to the root
while enforcing permission boundaries: if a collaborator or public link
viewer only has access to a subtree, ancestors above the highest
accessible node are omitted to prevent leaking private workspace
information.
Exposes getDocumentBreadcrumbs on the frontend document service to
fetch document hierarchy paths, routing through authenticated or public
endpoints based on current session credentials.
Resolving breadcrumbs purely from server roundtrips causes noticeable
layout shifts when switching between documents.

The hook combines instantaneous optimistic breadcrumb construction
from the local sidebar tree Redux state with background server
fetching to load full ancestor paths for unexpanded or deep trees,
while keeping active title changes reactive.
Integrates breadcrumbs into the document top toolbar, rendering
ancestor links with title truncation and Notion-style slash dividers.

For deeply nested hierarchies beyond three levels, intermediate
ancestors are collapsed into an ellipsis dropdown menu to preserve space
for editing actions. Navigation supports both online router transitions
and offline custom event dispatching.
When a document shared with a collaborator has a parent document that
is not accessible to that collaborator, the document floats at the
root of the collaborator's Shared section.

Previously, document queries returned the owner's siblingOrderKey for
any document with a non-null parent. For floated documents whose
parents are inaccessible to the caller, this leaked the sibling
ordering of an unshared private hierarchy and prevented the
collaborator's personal positioning in user_document_orders from
taking effect.

We update DocumentListQueryHelper to batch-resolve parent access
when listing shared documents and return the caller's personal
user_document_orders key for any floated document whose parent is
inaccessible. Single-document responses in DocumentService similarly
resolve parent access before deciding between the personal order key
and the owner's sibling order key. In DocumentRepository,
findSharedWithUserId fetch-joins the parent reference to avoid N+1
queries during listing.
When moving a shared document whose parent is private to the owner
and not shared with the current user, the API response contains the
document's true backend parentId.

Previously, moveDocumentThunk.fulfilled attempted to attach the moved
node to state.nodes[newParentId], which does not exist in the caller's
shared tree state. As a result, the node became orphaned from the
root list and failed to render in the sidebar.

We determine whether the updated node's parent is present in the
shared tree node registry. If absent, the node is treated as a floated
root with an effectiveParentId of null, placing it into rootIds and
sorting by personal orderKey so that tree rendering, reachability,
and drag-and-drop constraints remain valid.
The converter unit tests mutate the JVM property that Maven supplies to
every test in the shared Surefire fork. Clearing it leaked state into later
Spring JPA tests, causing Hibernate to fail while constructing the OAuth token converter.

Capture and restore the pre-existing property around each test, while clearing it only
within the missing-key assertion. This keeps the fail-fast coverage intact and makes the
test suite independent of execution order.
@santhoshh-kumar
santhoshh-kumar merged commit df8b5e7 into main Sep 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant