Add manual page ordering with drag-and-drop (#45)
All checks were successful
CD / Build and push images (push) Successful in 3m1s
CI / Lint, typecheck, test (push) Successful in 2m13s
CI / Auth e2e pack (push) Successful in 2m36s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 3m1s
CI / Lint, typecheck, test (push) Successful in 2m13s
CI / Auth e2e pack (push) Successful in 2m36s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Enable the third sidebar sort mode — a freely defined order. - api: `PATCH /pages/:id/position` (before/after neighbour) recomputes only the moved page's fractional `sort_key`. Pure `sort-key.ts` helpers (`nextKeyOrRebalance`, `evenlySpacedKeys`) decide between the cheap single-key path and a full pond rebalance to evenly-spaced keys when a key would exceed MAX_SORT_KEY_LENGTH or the client's neighbours are stale; rebalance runs in one transaction. Order is server-authoritative. - web: enable 'manual' in the sort-mode switch; in manual mode the owner can reorder via native drag-and-drop (drop above/below by pointer half) or the keyboard (per-row up/down buttons), each announced through an aria-live region. Reordering is hidden while a label filter narrows the list. New pages already append at the end (create uses generateKeyBetween(last, null)). Pure `reorder.ts` neighbour helpers, unit-tested. - i18n: manual sort mode + reorder strings (de + en). - tests: sort-key property test (10.000 adversarial reorders never collide or overflow — rebalance verified); reposition db test (persist, server-order, sort-mode switch keeps manual order); reorder e2e pack (keyboard reorder persists across reload + identical on a fresh read; aria-live announced). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
parent
03e72242d3
commit
69b00fcf2f
@ -174,6 +174,16 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/labels.spec.ts
|
||||
|
||||
- name: Reset login rate limit before reorder pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
- name: Run reorder pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/reorder.spec.ts
|
||||
|
||||
- name: Dump server logs on failure
|
||||
if: failure()
|
||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||
|
||||
@ -18,8 +18,10 @@ import {
|
||||
PageListItemView,
|
||||
PageStateView,
|
||||
PageView,
|
||||
RepositionPageInput,
|
||||
UpdatePageInput,
|
||||
createPageInputSchema,
|
||||
repositionPageInputSchema,
|
||||
updatePageInputSchema,
|
||||
} from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
@ -103,6 +105,16 @@ export class PagesController {
|
||||
});
|
||||
}
|
||||
|
||||
/** Reposition a page in the manual sidebar order (issue #45). */
|
||||
@Patch('pages/:id/position')
|
||||
async reposition(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(repositionPageInputSchema)) input: RepositionPageInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PageView> {
|
||||
return this.pages.reposition(request.user!, id, input);
|
||||
}
|
||||
|
||||
@Patch('pages/:id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
PageListItemView,
|
||||
PageStateView,
|
||||
PageView,
|
||||
RepositionPageInput,
|
||||
SidebarSortMode,
|
||||
UpdatePageInput,
|
||||
pondSettingsSchema,
|
||||
@ -18,6 +19,7 @@ import { PinoLogger } from 'nestjs-pino';
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
||||
import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
|
||||
|
||||
/** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */
|
||||
@ -230,6 +232,79 @@ export class PagesService {
|
||||
return this.viewOf(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reposition a page in the manual sidebar order (issue #45). Recomputes only
|
||||
* the moved page's `sort_key` to a value between its two new neighbours; when
|
||||
* that key would grow too long (or the client's neighbours are stale) the
|
||||
* whole pond is rebalanced to evenly-spaced keys with the page dropped at the
|
||||
* target slot. The order is server-authoritative, so every viewer sees the
|
||||
* same sequence. Requires write access; the sort mode does not have to be
|
||||
* `manual` (the key is stored regardless, just not applied in other modes).
|
||||
*/
|
||||
async reposition(user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
||||
const page = await this.findModifiablePage(user, id);
|
||||
const { afterId, beforeId } = input;
|
||||
if (afterId === id || beforeId === id) {
|
||||
throw new ConflictException({ code: 'bad_request' });
|
||||
}
|
||||
|
||||
const [afterPage, beforePage] = await Promise.all([
|
||||
afterId
|
||||
? this.prisma.page.findFirst({
|
||||
where: { id: afterId, pondId: page.pondId, deletedAt: null },
|
||||
select: { sortKey: true },
|
||||
})
|
||||
: null,
|
||||
beforeId
|
||||
? this.prisma.page.findFirst({
|
||||
where: { id: beforeId, pondId: page.pondId, deletedAt: null },
|
||||
select: { sortKey: true },
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
if (afterId && !afterPage) throw new NotFoundException();
|
||||
if (beforeId && !beforePage) throw new NotFoundException();
|
||||
|
||||
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
|
||||
if (key !== null) {
|
||||
const updated = await this.prisma.page.update({ where: { id }, data: { sortKey: key } });
|
||||
return this.viewOf(updated);
|
||||
}
|
||||
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign evenly-spaced `sort_key`s to every page in the pond, with the
|
||||
* moved page inserted at the slot implied by `afterId`/`beforeId`. Runs in one
|
||||
* transaction so the order is never observed half-rebalanced.
|
||||
*/
|
||||
private async rebalanceAndPlace(
|
||||
pondId: string,
|
||||
movedId: string,
|
||||
afterId: string | null,
|
||||
beforeId: string | null,
|
||||
): Promise<PageView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const pages = await tx.page.findMany({
|
||||
where: { pondId, deletedAt: null },
|
||||
orderBy: { sortKey: 'asc' },
|
||||
select: { id: true },
|
||||
});
|
||||
const order = pages.map((p) => p.id).filter((pid) => pid !== movedId);
|
||||
let index = order.length;
|
||||
if (afterId) index = order.indexOf(afterId) + 1;
|
||||
else if (beforeId) index = Math.max(0, order.indexOf(beforeId));
|
||||
order.splice(index, 0, movedId);
|
||||
|
||||
const keys = evenlySpacedKeys(order.length);
|
||||
await Promise.all(
|
||||
order.map((pid, i) => tx.page.update({ where: { id: pid }, data: { sortKey: keys[i]! } })),
|
||||
);
|
||||
this.logger.info({ pondId, movedId, pages: order.length }, 'audit: sort keys rebalanced');
|
||||
return this.viewOf(await tx.page.findUniqueOrThrow({ where: { id: movedId } }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Markdown export (issue #30) — serves the already-derived
|
||||
* `page_content_cache.markdown` (refreshed on every state save, #23)
|
||||
* rather than re-decoding the Yjs state, so export always matches what
|
||||
|
||||
108
apps/api/src/pages/reposition.db.test.ts
Normal file
108
apps/api/src/pages/reposition.db.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient, User } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTestApp } from '../testing/test-app';
|
||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { PagesService } from './pages.service';
|
||||
|
||||
describe.skipIf(!hasTestDb)('PagesService.reposition (db, issue #45)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let pages: PagesService;
|
||||
const suffix = uniqueSuffix();
|
||||
let owner: User;
|
||||
let pondId: string;
|
||||
|
||||
/** Current manual order of the pond's pages, as the sidebar would render it. */
|
||||
async function manualOrder(): Promise<string[]> {
|
||||
const list = await pages.list(owner, pondId);
|
||||
return list.map((p) => p.title);
|
||||
}
|
||||
|
||||
async function pageIdByTitle(title: string): Promise<string> {
|
||||
const p = await prisma.page.findFirstOrThrow({ where: { pondId, title } });
|
||||
return p.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrisma();
|
||||
app = await createTestApp();
|
||||
pages = app.get(PagesService);
|
||||
|
||||
owner = await prisma.user.create({
|
||||
data: {
|
||||
username: `pos-owner-${suffix}`,
|
||||
email: `pos-owner-${suffix}@example.test`,
|
||||
displayName: 'Position Owner',
|
||||
},
|
||||
});
|
||||
const pond = await prisma.pond.create({
|
||||
data: {
|
||||
slug: `pos-pond-${suffix}`,
|
||||
name: 'Position Pond',
|
||||
type: 'PERSONAL',
|
||||
ownerId: owner.id,
|
||||
},
|
||||
});
|
||||
pondId = pond.id;
|
||||
// Manual mode so `list` orders by sort_key.
|
||||
await prisma.pond.update({
|
||||
where: { id: pondId },
|
||||
data: { settings: { sidebarSort: 'manual' } },
|
||||
});
|
||||
|
||||
// Four pages created in order A, B, C, D (each appended at the end).
|
||||
for (const title of ['A', 'B', 'C', 'D']) {
|
||||
await pages.create(owner, pondId, { title });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.page.deleteMany({ where: { pondId } });
|
||||
await prisma.pond.deleteMany({ where: { id: pondId } });
|
||||
await prisma.user.deleteMany({ where: { id: owner.id } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('starts in creation order', async () => {
|
||||
expect(await manualOrder()).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('moves a page and the new order is server-ordered (persisted for everyone)', async () => {
|
||||
const d = await pageIdByTitle('D');
|
||||
const a = await pageIdByTitle('A');
|
||||
const b = await pageIdByTitle('B');
|
||||
// Move D to sit between A and B → A, D, B, C.
|
||||
await pages.reposition(owner, d, { afterId: a, beforeId: b });
|
||||
expect(await manualOrder()).toEqual(['A', 'D', 'B', 'C']);
|
||||
|
||||
// A fresh read (any other user's view) sees the same order — it lives in
|
||||
// sort_key, not client state.
|
||||
const reread = (await pages.list(owner, pondId)).map((p) => p.title);
|
||||
expect(reread).toEqual(['A', 'D', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('moves a page to the very top (afterId null)', async () => {
|
||||
const c = await pageIdByTitle('C');
|
||||
const a = await pageIdByTitle('A');
|
||||
await pages.reposition(owner, c, { afterId: null, beforeId: a });
|
||||
expect(await manualOrder()).toEqual(['C', 'A', 'D', 'B']);
|
||||
});
|
||||
|
||||
it('keeps the manual order when the sort mode changes (just not applied)', async () => {
|
||||
await prisma.pond.update({
|
||||
where: { id: pondId },
|
||||
data: { settings: { sidebarSort: 'alpha' } },
|
||||
});
|
||||
expect(await manualOrder()).toEqual(['A', 'B', 'C', 'D']); // alpha ignores sort_key
|
||||
|
||||
await prisma.pond.update({
|
||||
where: { id: pondId },
|
||||
data: { settings: { sidebarSort: 'manual' } },
|
||||
});
|
||||
// The manual order from before is intact — switching modes never rewrote keys.
|
||||
expect(await manualOrder()).toEqual(['C', 'A', 'D', 'B']);
|
||||
});
|
||||
});
|
||||
73
apps/api/src/pages/sort-key.test.ts
Normal file
73
apps/api/src/pages/sort-key.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MAX_SORT_KEY_LENGTH, evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
||||
|
||||
describe('sort-key helpers (issue #45)', () => {
|
||||
it('produces a key strictly between two neighbours', () => {
|
||||
const key = nextKeyOrRebalance('a0', 'a1');
|
||||
expect(key).not.toBeNull();
|
||||
expect(key! > 'a0' && key! < 'a1').toBe(true);
|
||||
});
|
||||
|
||||
it('signals rebalance (null) when neighbours are out of order', () => {
|
||||
expect(nextKeyOrRebalance('a1', 'a0')).toBeNull();
|
||||
expect(nextKeyOrRebalance('a0', 'a0')).toBeNull();
|
||||
});
|
||||
|
||||
it('evenlySpacedKeys returns n sorted, unique keys', () => {
|
||||
const keys = evenlySpacedKeys(100);
|
||||
expect(keys).toHaveLength(100);
|
||||
expect(new Set(keys).size).toBe(100);
|
||||
expect([...keys].sort()).toEqual(keys);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property test (acceptance criterion): 10.000 reorders in the adversarial
|
||||
* pattern — repeatedly drop the last page between the first two — must never
|
||||
* collide and never overflow the key length, because the caller rebalances
|
||||
* when {@link nextKeyOrRebalance} returns null.
|
||||
*/
|
||||
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
|
||||
// Start with five pages in a fixed order.
|
||||
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
||||
let rebalances = 0;
|
||||
|
||||
const rebalance = (): void => {
|
||||
const keys = evenlySpacedKeys(order.length);
|
||||
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
||||
rebalances += 1;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10_000; i += 1) {
|
||||
// Move the last page to sit between the first and second — the tightest
|
||||
// possible gap, which is what grows key length fastest.
|
||||
const moved = order[order.length - 1]!;
|
||||
const rest = order.slice(0, -1);
|
||||
const afterKey = rest[0]!.key;
|
||||
const beforeKey = rest[1]!.key;
|
||||
|
||||
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
||||
if (key === null) {
|
||||
// Rebalance keeps the CURRENT order, then retry the move once.
|
||||
rebalance();
|
||||
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
||||
expect(k2).not.toBeNull();
|
||||
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
||||
} else {
|
||||
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
||||
}
|
||||
|
||||
// Invariants after every move: keys unique, bounded, and consistent with
|
||||
// the intended array order.
|
||||
const keys = order.map((p) => p.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
||||
for (let j = 1; j < keys.length; j += 1) {
|
||||
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The adversarial pattern must have forced at least one rebalance.
|
||||
expect(rebalances).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
52
apps/api/src/pages/sort-key.ts
Normal file
52
apps/api/src/pages/sort-key.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
/**
|
||||
* Fractional-index keys for manual page ordering (issue #45, data-model.md
|
||||
* `sort_key`). A move recomputes only the moved page's key as a value strictly
|
||||
* between its two new neighbours, so no sibling is rewritten. Repeatedly
|
||||
* inserting between two very close keys grows the key length; once a fresh key
|
||||
* would exceed {@link MAX_SORT_KEY_LENGTH} the caller rebalances the whole pond
|
||||
* to evenly-spaced keys instead. These helpers are pure so the rebalance
|
||||
* behaviour can be property-tested without a database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Length at which a newly generated key triggers a rebalance. Fractional keys
|
||||
* only grow under adversarial "always insert between the same tight pair"
|
||||
* sequences; a healthy tree stays far below this. 40 leaves generous headroom
|
||||
* over normal use while capping unbounded growth.
|
||||
*/
|
||||
export const MAX_SORT_KEY_LENGTH = 40;
|
||||
|
||||
/** A key strictly between `after` and `before` (either `null` for an open end). */
|
||||
export function keyBetween(after: string | null, before: string | null): string {
|
||||
return generateKeyBetween(after, before);
|
||||
}
|
||||
|
||||
/** `n` evenly-spaced keys spanning the whole range — used to rebalance a pond. */
|
||||
export function evenlySpacedKeys(n: number): string[] {
|
||||
if (n <= 0) return [];
|
||||
return generateNKeysBetween(null, null, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key for a page moved between `afterKey` and `beforeKey`, or `null` when
|
||||
* the result would be too long (or the neighbours are out of order, e.g. from a
|
||||
* stale client) and the caller must rebalance instead. Never throws.
|
||||
*/
|
||||
export function nextKeyOrRebalance(
|
||||
afterKey: string | null,
|
||||
beforeKey: string | null,
|
||||
): string | null {
|
||||
// Guard reversed/equal neighbours ourselves: generateKeyBetween throws only
|
||||
// on equal keys and silently returns a wrong key when after > before, so a
|
||||
// stale client could otherwise corrupt the order — force a rebalance instead.
|
||||
if (afterKey !== null && beforeKey !== null && afterKey >= beforeKey) return null;
|
||||
let key: string;
|
||||
try {
|
||||
key = keyBetween(afterKey, beforeKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return key.length > MAX_SORT_KEY_LENGTH ? null : key;
|
||||
}
|
||||
92
apps/web/e2e/reorder.spec.ts
Normal file
92
apps/web/e2e/reorder.spec.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { contextForUser } from './helpers';
|
||||
|
||||
/**
|
||||
* Manual page ordering pack (issue #45). Exercises the keyboard reorder path
|
||||
* (up/down buttons) — the same `moveTo` → `PATCH /pages/:id/position` code the
|
||||
* drag-and-drop uses — because native HTML5 drag events are unreliable to
|
||||
* simulate. Verifies the order is server-authoritative (persists across a
|
||||
* reload and is identical on a fresh read) and that moves are announced via
|
||||
* aria-live. Selectors are language-independent (CSS classes + arrow glyphs).
|
||||
*/
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
|
||||
|
||||
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
|
||||
const ponds = await context.request.get('/api/v1/ponds');
|
||||
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||
return { id: pond.id, slug: pond.slug };
|
||||
}
|
||||
|
||||
async function createPage(context: Ctx, pondId: string, title: string): Promise<{ slug: string }> {
|
||||
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } });
|
||||
return created.json();
|
||||
}
|
||||
|
||||
/** Relative order of the given titles among the sidebar's page links. */
|
||||
function relativeOrder(listTexts: string[], titles: string[]): number[] {
|
||||
return titles.map((title) => listTexts.findIndex((text) => text === title));
|
||||
}
|
||||
|
||||
test('manual reorder persists server-side and is announced', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const pond = await personalPond(context);
|
||||
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { sidebarSort: 'manual' } });
|
||||
|
||||
const ts = Date.now();
|
||||
const first = `R-First ${ts}`;
|
||||
const second = `R-Second ${ts}`;
|
||||
const third = `R-Third ${ts}`;
|
||||
const created = await createPage(context, pond.id, first);
|
||||
await createPage(context, pond.id, second);
|
||||
await createPage(context, pond.id, third);
|
||||
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(`/p/${pond.slug}/${created.slug}`);
|
||||
|
||||
const links = page.locator('.sidebar__pages .sidebar__page');
|
||||
await expect(links.filter({ hasText: third }).first()).toBeVisible();
|
||||
|
||||
// Manual mode shows creation order: first, second, third (relatively).
|
||||
const before = relativeOrder(await links.allTextContents(), [first, second, third]);
|
||||
expect(before[0]! < before[1]! && before[1]! < before[2]!).toBe(true);
|
||||
|
||||
// Move "first" down once via the keyboard button → order second, first, third.
|
||||
const firstRow = page.locator('.sidebar__page-item', {
|
||||
has: page.getByText(first, { exact: true }),
|
||||
});
|
||||
await firstRow.locator('.sidebar__reorder-btn', { hasText: '↓' }).click();
|
||||
|
||||
// aria-live announced the move (contains the title and a position number).
|
||||
const status = page.locator('.sidebar__announce');
|
||||
await expect(status).toContainText(first);
|
||||
await expect(status).toContainText(/\d/);
|
||||
|
||||
await expect(async () => {
|
||||
const order = relativeOrder(await links.allTextContents(), [second, first, third]);
|
||||
expect(order[0]! < order[1]! && order[1]! < order[2]!).toBe(true);
|
||||
}).toPass();
|
||||
|
||||
// Persisted server-side: a reload keeps the new order…
|
||||
await page.reload();
|
||||
await expect(links.filter({ hasText: first }).first()).toBeVisible();
|
||||
await expect(async () => {
|
||||
const order = relativeOrder(await links.allTextContents(), [second, first, third]);
|
||||
expect(order[0]! < order[1]! && order[1]! < order[2]!).toBe(true);
|
||||
}).toPass();
|
||||
|
||||
// …and a fresh read (any other user's view) sees the identical order.
|
||||
const reread = await (await context.request.get(`/api/v1/ponds/${pond.id}/pages`)).json();
|
||||
const titles = reread.map((p: { title: string }) => p.title);
|
||||
const idx = (t: string) => titles.indexOf(t);
|
||||
expect(idx(second) < idx(first) && idx(first) < idx(third)).toBe(true);
|
||||
} finally {
|
||||
// Reset so this fixture pond does not leak a non-default sort mode.
|
||||
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { sidebarSort: 'alpha' } });
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
@ -10,14 +10,14 @@ import { LabelChips } from '../labels/LabelChips';
|
||||
import { usePondLabels } from '../labels/use-pond-labels';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import { NewPageForm } from './NewPageForm';
|
||||
import { dropIndex, neighborsForMove } from './reorder';
|
||||
import { useCurrentPondRoute } from './use-pond-route';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
/** Modes offered in the switch; manual reordering (drag-and-drop) arrives with #45. */
|
||||
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created'];
|
||||
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
|
||||
|
||||
/**
|
||||
* Left sidebar: the current pond's page list, sort mode, active-page
|
||||
@ -33,6 +33,8 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
const pond = useQuery({
|
||||
queryKey: ['pond', pondSlug],
|
||||
@ -78,6 +80,25 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
|
||||
}
|
||||
|
||||
// Reordering is offered only in manual mode, to the owner, and while no label
|
||||
// filter narrows the list (the visible order would then be a subset).
|
||||
const canReorder =
|
||||
isOwner && pond.data?.settings.sidebarSort === 'manual' && filterIds.size === 0;
|
||||
|
||||
/** Move `movedId` to `newIndex` (in the list with itself removed), persist the
|
||||
* new position server-side, and announce it for screen readers. */
|
||||
async function moveTo(movedId: string, newIndex: number, title: string): Promise<void> {
|
||||
if (!pond.data || !pages.data) return;
|
||||
const orderedIds = pages.data.map((p) => p.id);
|
||||
const { afterId, beforeId } = neighborsForMove(orderedIds, movedId, newIndex);
|
||||
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
|
||||
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data.id] });
|
||||
const position = Math.max(0, Math.min(newIndex, orderedIds.length - 1)) + 1;
|
||||
setAnnouncement(
|
||||
t('layout.sidebar.reorder.moved', { title, position, count: orderedIds.length }),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
|
||||
@ -158,17 +179,72 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
|
||||
{visiblePages && visiblePages.length > 0 ? (
|
||||
<ul className="sidebar__pages">
|
||||
{visiblePages.map((p) => (
|
||||
<li key={p.id}>
|
||||
{visiblePages.map((p, index) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className={
|
||||
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : undefined
|
||||
}
|
||||
draggable={canReorder}
|
||||
onDragStart={
|
||||
canReorder
|
||||
? (event) => {
|
||||
setDraggedId(p.id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragEnd={canReorder ? () => setDraggedId(null) : undefined}
|
||||
onDragOver={canReorder ? (event) => event.preventDefault() : undefined}
|
||||
onDrop={
|
||||
canReorder
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
if (!draggedId || draggedId === p.id || !pages.data) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const after = event.clientY - rect.top > rect.height / 2;
|
||||
const orderedIds = pages.data.map((page) => page.id);
|
||||
const target = dropIndex(orderedIds, draggedId, p.id, after);
|
||||
const title =
|
||||
pages.data.find((page) => page.id === draggedId)?.title ?? '';
|
||||
void moveTo(draggedId, target, title);
|
||||
setDraggedId(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`/p/${pondSlug}/${p.slug}`}
|
||||
className={
|
||||
p.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'
|
||||
}
|
||||
aria-current={p.slug === pageSlug ? 'page' : undefined}
|
||||
draggable={false}
|
||||
>
|
||||
{p.title}
|
||||
</Link>
|
||||
{canReorder && (
|
||||
<span className="sidebar__reorder">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__reorder-btn"
|
||||
aria-label={t('layout.sidebar.reorder.up')}
|
||||
disabled={index === 0}
|
||||
onClick={() => void moveTo(p.id, index - 1, p.title)}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__reorder-btn"
|
||||
aria-label={t('layout.sidebar.reorder.down')}
|
||||
disabled={index === visiblePages.length - 1}
|
||||
onClick={() => void moveTo(p.id, index + 1, p.title)}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<LabelChips labelIds={p.labelIds} byId={byId} />
|
||||
</li>
|
||||
))}
|
||||
@ -198,6 +274,11 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
{t('layout.sidebar.newPage')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Keyboard/drag reordering announcements for screen readers. */}
|
||||
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
|
||||
{announcement}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
37
apps/web/src/layout/reorder.test.ts
Normal file
37
apps/web/src/layout/reorder.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { dropIndex, neighborsForMove } from './reorder';
|
||||
|
||||
const ids = ['A', 'B', 'C', 'D'];
|
||||
|
||||
describe('neighborsForMove (issue #45)', () => {
|
||||
it('moves up one: D between B and C', () => {
|
||||
// D at index 3 → newIndex 2 in the without-array [A, B, C].
|
||||
expect(neighborsForMove(ids, 'D', 2)).toEqual({ afterId: 'B', beforeId: 'C' });
|
||||
});
|
||||
|
||||
it('moves to the top: afterId null', () => {
|
||||
expect(neighborsForMove(ids, 'C', 0)).toEqual({ afterId: null, beforeId: 'A' });
|
||||
});
|
||||
|
||||
it('moves to the bottom: beforeId null', () => {
|
||||
expect(neighborsForMove(ids, 'A', 3)).toEqual({ afterId: 'D', beforeId: null });
|
||||
});
|
||||
|
||||
it('clamps an out-of-range index', () => {
|
||||
expect(neighborsForMove(ids, 'A', 99)).toEqual({ afterId: 'D', beforeId: null });
|
||||
expect(neighborsForMove(ids, 'A', -5)).toEqual({ afterId: null, beforeId: 'B' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropIndex (issue #45)', () => {
|
||||
it('drops before the target by default', () => {
|
||||
// Drop A onto C (upper half) → before C. without = [B, C, D], C at 1.
|
||||
expect(dropIndex(ids, 'A', 'C', false)).toBe(1);
|
||||
});
|
||||
|
||||
it('drops after the target on the lower half — reaches the bottom', () => {
|
||||
// Drop A onto D lower half → after D. without = [B, C, D], D at 2 → 3 (end).
|
||||
expect(dropIndex(ids, 'A', 'D', true)).toBe(3);
|
||||
});
|
||||
});
|
||||
37
apps/web/src/layout/reorder.ts
Normal file
37
apps/web/src/layout/reorder.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Pure helpers for manual page reordering in the sidebar (issue #45). The
|
||||
* server recomputes the moved page's `sort_key` between two neighbours, so the
|
||||
* client only has to name them. Given the current order and where the page
|
||||
* should land, these compute the `afterId`/`beforeId` the position endpoint
|
||||
* expects — kept separate from the component so they are unit-testable.
|
||||
*/
|
||||
|
||||
/** The moved page's new neighbours when it lands at `newIndex` in the list with
|
||||
* itself removed. `afterId` is its predecessor (null at the top), `beforeId`
|
||||
* its successor (null at the bottom). */
|
||||
export function neighborsForMove(
|
||||
orderedIds: string[],
|
||||
movedId: string,
|
||||
newIndex: number,
|
||||
): { afterId: string | null; beforeId: string | null } {
|
||||
const without = orderedIds.filter((id) => id !== movedId);
|
||||
const clamped = Math.max(0, Math.min(newIndex, without.length));
|
||||
return {
|
||||
afterId: clamped > 0 ? without[clamped - 1]! : null,
|
||||
beforeId: clamped < without.length ? without[clamped]! : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Target index for a drop onto `targetId`: before it, or after it when the
|
||||
* pointer is over the item's lower half (so the very bottom is reachable). */
|
||||
export function dropIndex(
|
||||
orderedIds: string[],
|
||||
movedId: string,
|
||||
targetId: string,
|
||||
after: boolean,
|
||||
): number {
|
||||
const without = orderedIds.filter((id) => id !== movedId);
|
||||
const base = without.indexOf(targetId);
|
||||
if (base === -1) return without.length;
|
||||
return after ? base + 1 : base;
|
||||
}
|
||||
@ -1129,3 +1129,48 @@ button {
|
||||
.label-picker__empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Manual page reordering (issue #45) ----------------------------------- */
|
||||
|
||||
.sidebar__page-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sidebar__page-item .sidebar__page {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar__page-item--draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sidebar__reorder {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sidebar__reorder-btn {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text-muted);
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar__reorder-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar__reorder-btn:not(:disabled):hover {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@ -9,7 +9,14 @@
|
||||
"sortLabel": "Seiten sortieren",
|
||||
"sortMode": {
|
||||
"alpha": "A–Z",
|
||||
"created": "Erstellungsdatum"
|
||||
"created": "Erstellungsdatum",
|
||||
"manual": "Manuelle Reihenfolge"
|
||||
},
|
||||
"reorder": {
|
||||
"up": "Nach oben",
|
||||
"down": "Nach unten",
|
||||
"dragHint": "Zum Umsortieren ziehen",
|
||||
"moved": "{{title}} an Position {{position}} von {{count}} verschoben."
|
||||
},
|
||||
"newPage": "+ Neue Seite",
|
||||
"newPageTitle": "Titel",
|
||||
|
||||
@ -9,7 +9,14 @@
|
||||
"sortLabel": "Sort pages",
|
||||
"sortMode": {
|
||||
"alpha": "A–Z",
|
||||
"created": "Creation date"
|
||||
"created": "Creation date",
|
||||
"manual": "Manual order"
|
||||
},
|
||||
"reorder": {
|
||||
"up": "Move up",
|
||||
"down": "Move down",
|
||||
"dragHint": "Drag to reorder",
|
||||
"moved": "Moved {{title}} to position {{position}} of {{count}}."
|
||||
},
|
||||
"newPage": "+ New page",
|
||||
"newPageTitle": "Title",
|
||||
|
||||
@ -31,6 +31,18 @@ export const updatePageInputSchema = z
|
||||
.partial();
|
||||
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>;
|
||||
|
||||
/**
|
||||
* Move a page in the manual sidebar order (issue #45): place it between the
|
||||
* `afterId` page (its new predecessor) and the `beforeId` page (its new
|
||||
* successor); either is `null` at an end of the list. The server recomputes
|
||||
* only the moved page's `sort_key`.
|
||||
*/
|
||||
export const repositionPageInputSchema = z.object({
|
||||
afterId: z.string().min(1).nullable(),
|
||||
beforeId: z.string().min(1).nullable(),
|
||||
});
|
||||
export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
|
||||
|
||||
export const savePageStateInputSchema = z.object({
|
||||
/** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */
|
||||
state: z.string().min(1, 'validation.required'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user