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 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,8 @@ const patchSchema = z.object({
|
|||||||
title: z.string().min(1).max(500).optional(),
|
title: z.string().min(1).max(500).optional(),
|
||||||
body_md: z.string().optional(),
|
body_md: z.string().optional(),
|
||||||
body_html: z.string().nullable().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() });
|
const idParams = z.object({ id: z.string().uuid() });
|
||||||
|
|||||||
3
lib/db/migrations/020_page_position.sql
Normal file
3
lib/db/migrations/020_page_position.sql
Normal file
@@ -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);
|
||||||
@@ -45,8 +45,8 @@ export async function getBySlug(space_id, slug) {
|
|||||||
|
|
||||||
export async function listBySpace(space_id) {
|
export async function listBySpace(space_id) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, space_id, slug, title, parent_id, updated_at
|
`SELECT id, space_id, slug, title, parent_id, position, updated_at
|
||||||
FROM pages WHERE space_id=$1 ORDER BY title`, [space_id]
|
FROM pages WHERE space_id=$1 ORDER BY position, title`, [space_id]
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export async function update(id, patch, actor) {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
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 = [];
|
const sets = [], vals = [];
|
||||||
let i = 1;
|
let i = 1;
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
|
|||||||
@@ -11,10 +11,38 @@ function taskItem(t) {
|
|||||||
el('span', { class: 'status' + (t.status === 'blocked' ? ' bad' : '') }, t.status), ' ', t.title);
|
el('span', { class: 'status' + (t.status === 'blocked' ? ' bad' : '') }, t.status), ' ', t.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableRow(href, title, type) {
|
function pageLink(p) {
|
||||||
return el('tr', {},
|
return el('a', { href: '#/page/' + p.id }, p.title || '(untitled)');
|
||||||
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 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) {
|
export async function render(main, ctx) {
|
||||||
@@ -46,10 +74,6 @@ export async function render(main, ctx) {
|
|||||||
const projHead = el('h3', {}, 'Projects');
|
const projHead = el('h3', {}, 'Projects');
|
||||||
renderProjects();
|
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,
|
mount(main,
|
||||||
el('div', { class: 'doc-head' },
|
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.')),
|
tasks.length ? el('ul', { class: 'plain' }, tasks.map(taskItem)) : el('p', { class: 'muted' }, 'Clear board.')),
|
||||||
|
|
||||||
el('div', { class: 'card' },
|
el('div', { class: 'card' },
|
||||||
el('h3', {}, `Pages & references${rows.length ? ` (${pages.length + refs.length})` : ''}`),
|
el('h3', {}, `Pages & references${(pages.length + refs.length) ? ` (${pages.length + refs.length})` : ''}`),
|
||||||
rows.length
|
(pages.length + refs.length) > 0
|
||||||
? el('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '13px' } },
|
? el('div', {}, renderPageTree(pages, refs))
|
||||||
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('p', { class: 'muted' }, 'Nothing here yet.'))
|
: el('p', { class: 'muted' }, 'Nothing here yet.'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
22
tests/repos/pages_position.test.js
Normal file
22
tests/repos/pages_position.test.js
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user