Skip to content
Documentation GitHub
Workspace

Workspace Page Hierarchy

Workspace Page Hierarchy

Covers the parent-child page tree: creating children, deep nesting, moving pages, moving to root, and cycle prevention. This spec is P0 because cycle creation and cascade delete are data-loss risks — a cycle could break tree traversal and cause page data to become unreachable, while an accidental cascade delete can destroy an entire branch.

Preconditions

  • HTTP bridge running on port 9990
  • A workspace initialized via initialize_workspace before each scenario
  • Bridge shim injected via playwright.config.ts

Scenarios

Seed: seed.spec.ts

1. Create a child page under a parent

A page can be created as a direct child of an existing page from the sidebar.

Steps:

  1. Create a top-level page titled “Parent Page” via the “New Page” button.
  2. Right-click “Parent Page” in the sidebar and select “New Subpage” (or hover over it to reveal an inline “Add child page” button and click it).
  3. Type “Child Page” as the subpage title and confirm.

Expected: The sidebar shows “Parent Page” with a disclosure arrow. Expanding “Parent Page” reveals “Child Page” nested directly beneath it. The child is indented one level below the parent.

2. Child page is visible in the sidebar tree

The sidebar tree must reflect all parent-child relationships correctly.

Steps:

  1. Create a top-level page titled “Section One”.
  2. Create a subpage “Topic A” under “Section One”.
  3. Create a subpage “Topic B” under “Section One”.
  4. Expand “Section One” in the sidebar.

Expected: “Section One” shows two children: “Topic A” and “Topic B”, both at the same indentation level beneath it. No other pages appear nested under “Section One”.

3. Create a page at depth 3 (grandchild)

Pages can be nested multiple levels deep.

Steps:

  1. Create “Level 1” at the root.
  2. Create “Level 2” as a subpage of “Level 1”.
  3. Create “Level 3” as a subpage of “Level 2”.
  4. Expand “Level 1” then expand “Level 2” in the sidebar.

Expected: The sidebar shows a 3-level nesting: “Level 1” → “Level 2” → “Level 3”. Clicking “Level 3” opens it in the editor and displays its title correctly.

4. Create a page at depth 5 (deep nesting)

The system must handle deep hierarchies without errors.

Steps:

  1. Create five pages in sequence, each as a subpage of the previous, titled “D1” through “D5”.
  2. Expand each level in the sidebar to reveal the next.
  3. Click “D5” to navigate to it.

Expected: All 5 levels of nesting are visible in the sidebar when each level is expanded. “D5” opens correctly in the editor. The sidebar does not show any errors or broken tree nodes.

5. Get descendant count for a page with children

Before deleting a page with children, the delete confirmation dialog shows how many descendants will be affected.

Steps:

  1. Create “Root” with two children “Child A” and “Child B”.
  2. Create “Grandchild” as a subpage of “Child A”.
  3. Right-click “Root” in the sidebar and select “Delete”.

Expected: A confirmation dialog appears warning that deleting “Root” will also delete its descendants. The dialog indicates 3 descendants (or shows a total of 4 pages including “Root” itself). The count is accurate.

6. Get descendant count for a leaf page

A page with no children shows a count of zero descendants in the delete confirmation.

Steps:

  1. Create a top-level page titled “Leaf” with no subpages.
  2. Right-click “Leaf” in the sidebar and select “Delete”.

Expected: The confirmation dialog (if shown) indicates this page has no children/descendants, or simply asks for confirmation to delete this single page. No descendant count or a count of 0 is shown.

7. Move a page to a new parent

A page can be relocated in the hierarchy by dragging it in the sidebar or using a move action.

Steps:

  1. Create two root-level pages: “Origin” and “Destination”.
  2. Create “Traveler” as a subpage of “Origin”.
  3. Drag “Traveler” from under “Origin” and drop it onto “Destination” in the sidebar (or right-click “Traveler” → Move to… → select “Destination”).
  4. Expand “Destination” in the sidebar.

Expected: “Traveler” disappears from under “Origin” and appears as a child of “Destination”. Clicking “Traveler” in its new location opens it in the editor correctly. “Origin” no longer shows “Traveler” as a child.

8. Move a page to root

A page can be promoted to root level by moving it out of its parent.

Steps:

  1. Create “Container” and create “Escapee” as a subpage of “Container”.
  2. Drag “Escapee” out of “Container” and drop it at the root level of the sidebar (or right-click “Escapee” → Move to… → Root / Top level).

Expected: “Escapee” appears at the root level in the sidebar, no longer nested under “Container”. “Container” no longer shows “Escapee” as a child. Both pages remain accessible.

9. Move carries children along with the parent

When a page with children is moved, its entire subtree moves with it.

Steps:

  1. Create “Branch” with two subpages: “Leaf A” and “Leaf B”.
  2. Create a separate root-level page “New Home”.
  3. Drag “Branch” and drop it onto “New Home” (or use Move to… → “New Home”).
  4. Expand “New Home” then expand “Branch” in the sidebar.

Expected: “Branch” is now nested under “New Home”. Expanding “Branch” reveals both “Leaf A” and “Leaf B” still nested beneath it. All three pages retain their content and are accessible.

10. Move page — cycle prevention: page cannot be moved under itself

Attempting to drag a page onto itself must be blocked.

Steps:

  1. Create “Alpha” at the root level.
  2. Attempt to drag “Alpha” and drop it onto itself in the sidebar.

Expected: The drop is rejected — “Alpha” does not become a child of itself. The sidebar shows “Alpha” remaining at root. An error indicator or visual feedback shows the drop is invalid (e.g., a “not allowed” cursor or a toast error like “Cannot move a page under itself”).

11. Move page — cycle prevention: page cannot be moved under its own descendant

Attempting to move a page to be a child of one of its descendants creates a cycle and must be blocked.

Steps:

  1. Create a 3-level hierarchy: “Ancestor” → “Middle” → “Leaf”.
  2. Attempt to drag “Ancestor” and drop it onto “Middle” (or use Move to… → “Middle”).

Expected: The move is rejected. An error toast or feedback message appears (e.g., “Cannot move a page to one of its descendants” or “This would create a cycle”). The hierarchy remains unchanged — “Ancestor” stays at its original position.

12. Move page — cycle prevention: multi-level descendant

A deeper cycle attempt (target is a grandchild of the moving page) must also be caught.

Steps:

  1. Create “Root” → “Branch” → “Twig” (3-level chain).
  2. Attempt to drag “Root” and drop it onto “Twig”.

Expected: The move is rejected. The error message indicates a cycle would be created. “Root” remains at its current position. The hierarchy is unchanged.

13. Move page — target parent does not exist

Attempting to move a page using a context menu “Move to” and selecting a destination that was deleted mid-flow should surface an error.

Steps:

  1. Create “Solo” at the root level.
  2. Attempt to move “Solo” to a parent that does not exist (e.g., type a non-existent page name in a “Move to” search field, or attempt via API with ghost-parent).

Expected: The move fails with an error or the destination is simply not available in the picker. “Solo” remains at root. An appropriate message is shown.

14. Move page — source page does not exist

Attempting to move a page that does not exist surfaces a not-found state.

Steps:

  1. Attempt to move a page that has been deleted or that was never created (e.g., by initiating a move on a stale reference).

Expected: An error or “Page not found” state is shown. No pages are moved. The sidebar is consistent.

15. Move page — deleted page cannot be moved

Soft-deleted pages are not visible in the sidebar and cannot be moved.

Steps:

  1. Create “Zombie” and then delete it (right-click → Delete → confirm).
  2. Attempt to find “Zombie” in the sidebar to drag or move it.

Expected: “Zombie” is not visible in the active sidebar page tree (it is in the trash). It cannot be selected for a move operation. The hierarchy is unaffected.

16. Move a page between two deep paths

A page can be moved from one deep path to another deep path.

Steps:

  1. Build a hierarchy: “A” → “B” → “C” (3 levels).
  2. Build a second hierarchy: “X” → “Y” (2 levels).
  3. Drag “C” out of “A/B” and drop it onto “Y” under “X”.
  4. Expand “X” then “Y” in the sidebar.

Expected: “C” appears nested under “Y” (which is under “X”). Clicking “C” at its new location opens it in the editor. The old path “A/B/C” no longer contains “C”.

17. Descendant count after move reflects new hierarchy

After moving a page out of a parent, the parent’s descendant count in the delete confirmation decreases accordingly.

Steps:

  1. Create “Container” with subpages “Item A” and “Item B”.
  2. Right-click “Container” → Delete to see the current descendant count (2 descendants). Cancel the delete.
  3. Move “Item A” to root level (drag it out of “Container”).
  4. Right-click “Container” → Delete again to see the updated descendant count.

Expected: After moving “Item A” out, the delete confirmation for “Container” shows 1 descendant (only “Item B” remains). The count decreased by 1, reflecting the moved page.

18. Page tree is consistent after a series of moves

Multiple sequential moves must leave the tree in a valid, consistent state with no orphaned nodes.

Steps:

  1. Create: “Home” with subpages “Section A” and “Section B”, and “Page 1” under “Section A”.
  2. Move “Page 1” from under “Section A” to under “Section B”.
  3. Move “Section B” (now containing “Page 1”) to root level.
  4. Observe the final sidebar tree.

Expected: The sidebar shows:

  • “Home” with one child: “Section A” (now empty or without “Page 1”).
  • “Section B” at root level with “Page 1” nested under it. All pages are accessible by clicking them. No orphaned or ghost nodes are visible.

19. Invalid slug in move_page is rejected

A slug with path-traversal characters must be rejected.

Steps:

  1. Attempt to trigger a move using a slug containing ../secret (e.g., via any direct slug input, if available, or by manipulating the move request).

Expected: A validation error is returned. No filesystem access occurs. The page tree is unchanged.

20. Move page to same parent is a no-op (idempotent)

Moving a page to its current parent should succeed without changing anything.

Steps:

  1. Create “Parent” with a subpage “Child”.
  2. Attempt to drag “Child” and drop it back onto “Parent” (its current parent).

Expected: The move operation completes without error. “Child” remains nested under “Parent” at the same level. The hierarchy is unchanged and “Child” is still accessible.

Test Data

KeyValueNotes
max_depth_tested5Scenario 4 validates depth-5 nesting
cycle_self_slugalphaPage moved to itself; must be rejected
cycle_descendant_slugancestor → ancestor/middle/leaf3-level cycle; grandchild-as-parent must be rejected
traversal_slug../secretPath traversal in slug input; must be rejected
ghost_parent_slugghost-parentNon-existent parent target for move

Notes

  • Cycle detection in move_page_impl checks whether new_parent_slug == slug or new_parent_slug.starts_with(slug + "/"). This is a prefix-based check, not a full database ancestry walk. Scenarios 10–12 exercise the two branches of this check.
  • move_page returns the new slug as a bare string (not wrapped in an object). Test assertions should compare the response body directly to the expected slug string.
  • When moving a page, all descendant slugs are updated via a recursive slug-prefix rewrite in the database (UPDATE WHERE slug LIKE ‘old-slug/%’). This is a batch operation; tests that rely on descendant slugs after a move should re-derive them from the new parent prefix.
  • The MovePageUseCase requires Capability::PagesOrganize. In the bridge, all requests run as owner (all capabilities granted), so this is not a testing concern here. Capability-restricted scenarios are covered by unit tests in the application layer.
  • list_page_tree vs list_pages: list_page_tree returns tree nodes optimized for sidebar rendering; list_pages returns a flat list. Both should reflect the same set of live (non-deleted) pages.

Was this page helpful?