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']); + }); +});