Add nested documents with hierarchical sidebar, drag-and-drop and breadcrumbs - #94
Merged
Conversation
…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
force-pushed
the
feat/sidebar-tree
branch
from
September 1, 2026 12:26
0b58f7e to
475458e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
Overview of commits
Concerns