From 3f77f3faad8df576f5aef109553ad23c1975e308 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 22:33:10 +1000 Subject: [PATCH] feat(pages): explicit position ordering + sectioned space view Add position column to pages (migration 020), update listBySpace to ORDER BY position, title, expose position in update(), add to patchSchema, and replace the space view flat table with a tree renderer grouping pages by parent_id under h4 section headers. Co-Authored-By: Claude Opus 4.8 --- lib/api/routes/pages.js | 3 +- lib/db/migrations/020_page_position.sql | 3 ++ lib/db/repos/pages.js | 6 +-- public/views/space.js | 50 +++++++++++++++++-------- tests/repos/pages_position.test.js | 22 +++++++++++ 5 files changed, 65 insertions(+), 19 deletions(-) create mode 100644 lib/db/migrations/020_page_position.sql create mode 100644 tests/repos/pages_position.test.js diff --git a/lib/api/routes/pages.js b/lib/api/routes/pages.js index ecf6826..87743a2 100644 --- a/lib/api/routes/pages.js +++ b/lib/api/routes/pages.js @@ -19,7 +19,8 @@ const patchSchema = z.object({ title: z.string().min(1).max(500).optional(), body_md: z.string().optional(), body_html: z.string().nullable().optional(), - parent_id: z.string().uuid().nullable().optional() + parent_id: z.string().uuid().nullable().optional(), + position: z.number().int().optional() }); const idParams = z.object({ id: z.string().uuid() }); diff --git a/lib/db/migrations/020_page_position.sql b/lib/db/migrations/020_page_position.sql new file mode 100644 index 0000000..1fc8297 --- /dev/null +++ b/lib/db/migrations/020_page_position.sql @@ -0,0 +1,3 @@ +-- 020: explicit page ordering within a space (and within a parent). +ALTER TABLE pages ADD COLUMN IF NOT EXISTS position integer NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_pages_space_position ON pages (space_id, position, title); diff --git a/lib/db/repos/pages.js b/lib/db/repos/pages.js index f25006b..007f6a8 100644 --- a/lib/db/repos/pages.js +++ b/lib/db/repos/pages.js @@ -45,8 +45,8 @@ export async function getBySlug(space_id, slug) { export async function listBySpace(space_id) { const { rows } = await pool.query( - `SELECT id, space_id, slug, title, parent_id, updated_at - FROM pages WHERE space_id=$1 ORDER BY title`, [space_id] + `SELECT id, space_id, slug, title, parent_id, position, updated_at + FROM pages WHERE space_id=$1 ORDER BY position, title`, [space_id] ); return rows; } @@ -64,7 +64,7 @@ export async function update(id, patch, actor) { const client = await pool.connect(); try { await client.query('BEGIN'); - const fields = ['slug','title','body_md','body_html','parent_id','embedding']; + const fields = ['slug','title','body_md','body_html','parent_id','position','embedding']; const sets = [], vals = []; let i = 1; for (const f of fields) { diff --git a/public/views/space.js b/public/views/space.js index 155155e..5abd7c3 100644 --- a/public/views/space.js +++ b/public/views/space.js @@ -11,10 +11,38 @@ function taskItem(t) { el('span', { class: 'status' + (t.status === 'blocked' ? ' bad' : '') }, t.status), ' ', t.title); } -function tableRow(href, title, type) { - return el('tr', {}, - el('td', { style: { padding: '5px 8px', borderTop: '1px solid var(--border)' } }, el('a', { href }, title || '(untitled)')), - el('td', { class: 'muted', style: { padding: '5px 8px', borderTop: '1px solid var(--border)', width: '90px' } }, type)); +function pageLink(p) { + return el('a', { href: '#/page/' + p.id }, p.title || '(untitled)'); +} + +function renderPageTree(pages, refs) { + const byParent = new Map(); + for (const p of pages) { + const k = p.parent_id || '__root__'; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k).push(p); + } + const roots = byParent.get('__root__') || []; + const blocks = []; + for (const r of roots) { + const kids = byParent.get(r.id) || []; + blocks.push(el('div', { class: 'doc-section' }, + el('h4', { style: { margin: '12px 0 4px' } }, pageLink(r)), + kids.length + ? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + kids.map(k => { + const gk = byParent.get(k.id) || []; + return el('li', {}, pageLink(k), + gk.length ? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + gk.map(g => el('li', {}, pageLink(g)))) : null); + })) + : null)); + } + if (refs.length) blocks.push(el('div', { class: 'doc-section' }, + el('h4', { style: { margin: '12px 0 4px' } }, 'References'), + el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } }, + refs.map(rf => el('li', {}, el('a', { href: '#/ref/' + rf.id }, rf.title || rf.source_url)))))); + return blocks; } export async function render(main, ctx) { @@ -46,10 +74,6 @@ export async function render(main, ctx) { const projHead = el('h3', {}, 'Projects'); renderProjects(); - const rows = [ - ...pages.map(p => tableRow('#/page/' + p.id, p.title, 'page')), - ...refs.map(r => tableRow('#/ref/' + r.id, r.title || r.source_url, r.kind)) - ]; mount(main, el('div', { class: 'doc-head' }, @@ -75,13 +99,9 @@ export async function render(main, ctx) { tasks.length ? el('ul', { class: 'plain' }, tasks.map(taskItem)) : el('p', { class: 'muted' }, 'Clear board.')), el('div', { class: 'card' }, - el('h3', {}, `Pages & references${rows.length ? ` (${pages.length + refs.length})` : ''}`), - rows.length - ? el('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '13px' } }, - el('thead', {}, el('tr', {}, - el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', fontWeight: '500' } }, 'Title'), - el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', width: '90px', fontWeight: '500' } }, 'Type'))), - el('tbody', {}, rows)) + el('h3', {}, `Pages & references${(pages.length + refs.length) ? ` (${pages.length + refs.length})` : ''}`), + (pages.length + refs.length) > 0 + ? el('div', {}, renderPageTree(pages, refs)) : el('p', { class: 'muted' }, 'Nothing here yet.')) ); } diff --git a/tests/repos/pages_position.test.js b/tests/repos/pages_position.test.js new file mode 100644 index 0000000..779df24 --- /dev/null +++ b/tests/repos/pages_position.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { create, listBySpace, update } from '../../lib/db/repos/pages.js'; +import { create as createSpace } from '../../lib/db/repos/spaces.js'; + +const actor = { kind: 'user', id: null }; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('page ordering', () => { + it('orders by position then title', async () => { + const space = await createSpace({ slug: 'ord-test', name: 'Ord' }, actor); + const sid = space.id; + const a = await create({ space_id: sid, slug: 'a', title: 'Zzz', body_md: '' }, actor); + const b = await create({ space_id: sid, slug: 'b', title: 'Aaa', body_md: '' }, actor); + await update(a.id, { position: 1 }, actor); + await update(b.id, { position: 9 }, actor); + const list = await listBySpace(sid); + expect(list.map(p => p.title)).toEqual(['Zzz', 'Aaa']); + }); +});