Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/02-data-model-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,19 @@ extension sorts after it — so a gap can always be subdivided, however often.

Consequences worth knowing:

- Dropping a page **onto** another one clears its key: in its new level it
sorts by title until somebody drags it into place. That is predictable, and
it avoids carrying a key from a level where it meant something else.
- Choosing a **parent** clears the key: in its new level the page sorts by
title until somebody puts it in place. That is predictable, and it avoids
carrying a key from a level where it meant something else. It holds for every
way of choosing one — dropping the page onto a row, and the move menu's "in"
and "Move to…" alike ([06](06-ui-information-architecture.md)) — so the tree
does not depend on which input device moved the page. A key is written only
where a position within a level is actually chosen.
- Two pages whose titles normalise identically compare equal; the slug breaks
the tie, so every client shows the same order.
the tie, so every client shows the same order. A position *between* two of
them is then not expressible as a key at all, which the move menu's up and
down have to answer for: they step past the whole run of equal keys rather
than into it. One row further than asked, and a move — the alternative is a
key the page already has, published as nothing at all.
- **Open:** the order is per page, so a *level* cannot be sorted in one go, and
reordering needs a signature per page moved.
- **Open:** nothing cleans up the placement of a deleted page. A `31818` whose
Expand Down
50 changes: 47 additions & 3 deletions docs/06-ui-information-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,53 @@ suffocates in a column that narrow, so it gets `max-w-4xl`.

The page itself carries **no** move action any more: a picker on the page
was tried and dropped again — dragging in the tree says where a page ends up
far better than a list of slugs can. **Open:** moving therefore has no
keyboard path, and none on a touch screen either, where HTML5 drag & drop
does not fire.
far better than a list of slugs can.

**Without a pointer that can drag (CON-15).** Dragging stays the primary
gesture, but it is not a reachable one: HTML5 drag & drop has no keyboard
path at all, and on a touch screen it does not fire even once — there,
moving a page was impossible rather than awkward. Every row therefore
carries a **move button** (`src/ui/PageMoveMenu.tsx`), a real button in the
tab order and under a finger, opening a menu with two panels:

- the four steps every outline editor has — **up**, **down**, **in** under
the sibling above, **out** to directly behind the parent. The two that
change a page's parent name it ("Move under Handbook", "Move out of
Handbook"); up and down stay generic, because the row they pass is the
one directly above or below and pointing at it adds nothing. A step with
nowhere to go is drawn disabled rather than reporting an error after the
click — and so is one that would land the page exactly where it already
hangs, which `useMovePage` would otherwise drop without a word, leaving an
enabled entry that does nothing. Where each one lands is
`src/domain/move-tree.ts`.
- **Move to…**, the list of every page it may be filed under plus the top
level, in tree order and filterable by name. This is what dragging has no
equivalent of, deliberately: a drag can only end where the pointer can
reach, a list can name a row that is scrolled away or folded shut. Left
out are the page itself, its own subtree and the parent it already has —
the same three a drop refuses.

A step that picks a *parent* ("in", "Move to…") writes **no** order key and
lets the new level sort by title, which is exactly what dropping onto that
row does. The tree must not look different depending on whether a mouse or
the keyboard moved the page. A key is written only where a position within a
level is genuinely being chosen: up, down and out.

The trigger is hidden until the row is hovered or something inside it is
focused, like the "+" on the Pages heading — the tree is read far more often
than it is rearranged. A coarse pointer has no hover to reveal it with, so
`pointer-coarse` leaves it permanently visible there; without that line the
touch half of this would have shipped invisible.

The open panel is **portalled into the body** and positioned against the
window rather than drawn inside the row, for two reasons that both bite
exactly where the menu matters most. The tree scrolls in an
`overflow-y-auto` container, which clips anything absolutely positioned
inside it: a row in the lower part of the bar would open a menu with its
lower half — the destination list — cut away. And every row is a
`draggable` element, so a press inside the menu (selecting filter text,
sliding onto an entry) would be handed to the row as the start of a drag.
The panel flips above the row when the window has no room below it.
4. **Settings** — one row to `/settings/spaces`, the entry into the settings
hub (below). Stays visible even while already inside `/settings/*`, where
the bar shows the hub's own nav (Profile/Spaces) instead of zones 2 and 3.
Expand Down
178 changes: 178 additions & 0 deletions src/domain/move-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import { buildPages, buildTree, flattenTree, orderKeyOf } from './pages'
import { planMove, siblingsOf } from './move-tree'
import type { MoveDirection } from './move-tree'
import type { Page } from './pages'
import type { Revision } from './revision'

function rev(partial: Partial<Revision> & { id: string }): Revision {
return {
author: 'alice',
createdAt: 1000,
group: 'engineering',
slug: partial.id,
title: partial.id,
parentSlug: null,
order: null,
parentRevs: [],
summary: null,
content: '',
...partial,
}
}

/** A tree from `slug:parent` pairs, with each page's title as its own slug. */
function tree(...spec: [slug: string, parent: string | null][]): Page[] {
return buildPages(spec.map(([slug, parent]) => rev({ id: slug, parentSlug: parent })))
}

/**
* The move applied, as `buildPages` would hand the result back.
*
* The re-sort is the point, not bookkeeping: `planMove` reads a level by
* filtering an already globally sorted array, which is what `buildPages`
* produces and what `space.pages` always is. A test that changed one key and
* left the array where it was would be asking the function a question it never
* gets in the app — and would answer the second move from the tree as it
* looked before the first.
*/
function applyMove(pages: Page[], slug: string, direction: MoveDirection): Page[] {
const move = planMove(pages, slug, direction)
if (!move) throw new Error(`${direction} was not available for ${slug}`)
return pages
.map((page) =>
page.slug === slug ? { ...page, parentSlug: move.parentSlug, order: move.order } : page,
)
.sort((a, b) => {
const left = orderKeyOf(a)
const right = orderKeyOf(b)
if (left !== right) return left < right ? -1 : 1
return a.slug < b.slug ? -1 : 1
})
}

/**
* The tree somebody would see after the move, indented — an order key is not
* what anybody is checking, the row it puts the page on is.
*/
function after(pages: Page[], slug: string, direction: MoveDirection): string[] {
return flattenTree(buildTree(applyMove(pages, slug, direction))).map(
(node) => `${' '.repeat(node.depth)}${node.slug}`,
)
}

const FLAT = tree(['a', null], ['b', null], ['c', null], ['d', null])

/**
* A level whose three titles all normalise to the same key, so all three sort
* equal and only the slug tells them apart (docs/02). Reachable without
* trying: a rename keeps the page's slug (`src/ui/PageEditor.tsx`), so two
* pages can end up named the same thing.
*/
const TIED = buildPages([
rev({ id: 'a1', title: 'Setup' }),
rev({ id: 'b2', title: 'setup' }),
rev({ id: 'c3', title: 'SETUP' }),
])

describe('siblingsOf', () => {
it('keeps the level in the order the tree draws it', () => {
expect(siblingsOf(FLAT, null).map((page) => page.slug)).toEqual(['a', 'b', 'c', 'd'])
const nested = tree(['a', null], ['x', 'a'], ['y', 'a'])
expect(siblingsOf(nested, 'a').map((page) => page.slug)).toEqual(['x', 'y'])
})
})

describe('planMove — up and down', () => {
it('swaps with the row above, and with the row below', () => {
expect(after(FLAT, 'c', 'up')).toEqual(['a', 'c', 'b', 'd'])
expect(after(FLAT, 'b', 'down')).toEqual(['a', 'c', 'b', 'd'])
})

it('reaches the first and the last position, not just the middle', () => {
expect(after(FLAT, 'b', 'up')).toEqual(['b', 'a', 'c', 'd'])
expect(after(FLAT, 'c', 'down')).toEqual(['a', 'b', 'd', 'c'])
})

it('is a round trip: up and back down leaves the order it found', () => {
expect(after(applyMove(FLAT, 'c', 'up'), 'c', 'down')).toEqual(['a', 'b', 'c', 'd'])
expect(after(applyMove(FLAT, 'b', 'down'), 'b', 'up')).toEqual(['a', 'b', 'c', 'd'])
})

it('has nowhere to go at the ends of a level', () => {
expect(planMove(FLAT, 'a', 'up')).toBeNull()
expect(planMove(FLAT, 'd', 'down')).toBeNull()
})

it('counts siblings, not rows: a subtree in between is stepped over whole', () => {
const nested = tree(['a', null], ['b', null], ['deep', 'b'], ['c', null])
// `a` moving down passes `b` and everything hanging under it in one step
expect(after(nested, 'a', 'down')).toEqual(['b', ' deep', 'a', 'c'])
})

it('stays where it is when the level holds only one page', () => {
const only = tree(['a', null], ['x', 'a'])
expect(planMove(only, 'x', 'up')).toBeNull()
expect(planMove(only, 'x', 'down')).toBeNull()
})
})

describe('planMove — siblings that share an order key', () => {
it('steps past the whole run rather than landing in a gap that is not there', () => {
// One row would be the better answer and is not expressible as a key: all
// three sort equal, so there is nothing between them to aim at.
expect(after(TIED, 'a1', 'down')).toEqual(['b2', 'c3', 'a1'])
})

it('never hands back the key the page already has', () => {
const moved = applyMove(TIED, 'a1', 'down')
const back = planMove(moved, 'a1', 'up')
// The step that publishes nothing is the one that hurts: `useMovePage`
// drops a move that changes neither parent nor key silently, so the entry
// looks enabled and does nothing at all.
expect(back?.order).not.toBe(moved.find((page) => page.slug === 'a1')!.order)
expect(after(moved, 'a1', 'up')).toEqual(['a1', 'b2', 'c3'])
})
})

describe('planMove — in and out', () => {
it('files the page under the sibling above it', () => {
expect(after(FLAT, 'b', 'in')).toEqual(['a', ' b', 'c', 'd'])
})

it('writes no key of its own — the new level sorts it by its title', () => {
// The same answer dropping the page onto that row with a mouse gives, so
// the tree does not depend on which input device moved the page.
const nested = tree(['a', null], ['x', 'a'], ['y', 'a'], ['b', null])
expect(planMove(nested, 'b', 'in')).toEqual({ parentSlug: 'a', order: null })
expect(after(nested, 'b', 'in')).toEqual(['a', ' b', ' x', ' y'])
})

it('has no sibling above it to go in under', () => {
expect(planMove(FLAT, 'a', 'in')).toBeNull()
})

it('puts the page directly behind its parent, not at the end of that level', () => {
const nested = tree(['a', null], ['x', 'a'], ['b', null], ['c', null])
expect(after(nested, 'x', 'out')).toEqual(['a', 'x', 'b', 'c'])
})

it('keeps the page ahead of its parent-level neighbour it was never behind', () => {
const nested = tree(['a', null], ['x', 'a'], ['y', 'a'], ['b', null])
// both children come out one after the other and stay in their order
expect(after(applyMove(nested, 'x', 'out'), 'y', 'out')).toEqual(['a', 'y', 'x', 'b'])
})

it('cannot come out of the top level', () => {
expect(planMove(FLAT, 'a', 'out')).toBeNull()
})

it('takes the page and its own subtree along, in and out', () => {
const nested = tree(['a', null], ['b', null], ['deep', 'b'], ['deeper', 'deep'])
expect(after(nested, 'b', 'in')).toEqual(['a', ' b', ' deep', ' deeper'])
})

it('answers null for a slug the space does not have', () => {
expect(planMove(FLAT, 'nope', 'up')).toBeNull()
})
})
Loading
Loading