From 03e72242d3e164adf201a0481da24b4921984e88 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 9 Jul 2026 11:41:42 +0200 Subject: [PATCH] Add label UI: tree management, page assignment, and sidebar filter (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the M4 label experience on top of the #43 label API. - shared: `flattenLabelTree` (tree → depth-first list) for chip lookup, filtering, and the picker; `PageListItemView` adds each page's `labelIds` to the sidebar list response. - api: `GET /ponds/:id/pages` now includes `labelIds` per page (one grouped query), so the sidebar can render chips and filter without extra calls. - web: - Pond settings page (`/p/:pondSlug/settings`) with a `LabelManager` tree: inline create, rename, recolour (``), move via a parent picker that excludes the label's own subtree, and delete that confirms then force-detaches assigned pages. Every control is a native button/input/select — the tree is fully keyboard-operable. - `LabelPicker` panel on the page editor: searchable, hierarchy-indented multi-select that assigns/unassigns immediately and refreshes the page's labels and the sidebar. - Sidebar: colored label chips on page entries (readable text via a luminance-based contrast helper) and a descendant-inclusive label filter (selecting a parent matches pages tagged with its children, via the shared `collectSubtreeIds`). Owner link to pond settings. - i18n `labels` namespace (de + en). - e2e `labels.spec.ts` (new CI pack): full lifecycle from the settings UI and picker-assign + parent-filter-includes-child. Selectors are language-independent because the UI language follows the user's locale. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY --- .gitea/workflows/ci.yml | 10 + apps/api/src/pages/pages.controller.ts | 6 +- apps/api/src/pages/pages.service.ts | 12 +- apps/web/e2e/labels.spec.ts | 139 +++++++++++ apps/web/src/App.tsx | 3 + apps/web/src/i18n/index.ts | 4 + apps/web/src/labels/LabelChips.tsx | 37 +++ apps/web/src/labels/LabelManager.tsx | 297 ++++++++++++++++++++++++ apps/web/src/labels/LabelPicker.tsx | 114 +++++++++ apps/web/src/labels/label-color.test.ts | 18 ++ apps/web/src/labels/label-color.ts | 19 ++ apps/web/src/labels/use-pond-labels.ts | 85 +++++++ apps/web/src/layout/Sidebar.tsx | 92 +++++++- apps/web/src/pages/PageEditorPage.tsx | 16 ++ apps/web/src/pages/PondSettingsPage.tsx | 48 ++++ apps/web/src/styles/base.css | 198 ++++++++++++++++ packages/shared/i18n/de/labels.json | 43 ++++ packages/shared/i18n/en/labels.json | 43 ++++ packages/shared/src/labels.test.ts | 11 + packages/shared/src/labels.ts | 17 ++ packages/shared/src/pages.ts | 10 + 21 files changed, 1211 insertions(+), 11 deletions(-) create mode 100644 apps/web/e2e/labels.spec.ts create mode 100644 apps/web/src/labels/LabelChips.tsx create mode 100644 apps/web/src/labels/LabelManager.tsx create mode 100644 apps/web/src/labels/LabelPicker.tsx create mode 100644 apps/web/src/labels/label-color.test.ts create mode 100644 apps/web/src/labels/label-color.ts create mode 100644 apps/web/src/labels/use-pond-labels.ts create mode 100644 apps/web/src/pages/PondSettingsPage.tsx create mode 100644 packages/shared/i18n/de/labels.json create mode 100644 packages/shared/i18n/en/labels.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3c015ae..c23e00f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -164,6 +164,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/offline.spec.ts + - name: Reset login rate limit before labels 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 labels pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/labels.spec.ts + - name: Dump server logs on failure if: failure() run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index 4f913be..c7a26d4 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -15,6 +15,7 @@ import { import { CollabTokenResponse, CreatePageInput, + PageListItemView, PageStateView, PageView, UpdatePageInput, @@ -42,7 +43,10 @@ export class PagesController { } @Get('ponds/:pondId/pages') - async list(@Param('pondId') pondId: string, @Req() request: AuthedRequest): Promise { + async list( + @Param('pondId') pondId: string, + @Req() request: AuthedRequest, + ): Promise { return this.pages.list(request.user!, pondId); } diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index ec43b46..2d6f0ed 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common import { CollabTokenResponse, CreatePageInput, + PageListItemView, PageStateView, PageView, SidebarSortMode, @@ -110,16 +111,21 @@ export class PagesService { manual: { sortKey: 'asc' }, }; - /** Sidebar page list, ordered per the pond's persisted sort mode (issue #26). */ - async list(user: User, pondId: string): Promise { + /** Sidebar page list, ordered per the pond's persisted sort mode (issue #26), + * each with its assigned label ids for chips and filtering (issue #44). */ + async list(user: User, pondId: string): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); this.access.assertCanSee(user, pond); const settings = pondSettingsSchema.parse(pond.settings ?? {}); const pages = await this.prisma.page.findMany({ where: { pondId, deletedAt: null }, orderBy: PagesService.SORT_ORDER[settings.sidebarSort], + include: { labels: { select: { labelId: true } } }, }); - return pages.map((page) => this.viewOf(page)); + return pages.map((page) => ({ + ...this.viewOf(page), + labelIds: page.labels.map((l) => l.labelId), + })); } async create(user: User, pondId: string, input: CreatePageInput): Promise { diff --git a/apps/web/e2e/labels.spec.ts b/apps/web/e2e/labels.spec.ts new file mode 100644 index 0000000..0982d3d --- /dev/null +++ b/apps/web/e2e/labels.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Label UI pack (issue #44). Runs against the local dev stack (api + web). + * Selectors are language-independent (CSS classes + label names) because the + * UI language follows the signed-in user's profile locale, not the browser — + * so text-based selectors would be locale-dependent. Creates uniquely-named + * labels/pages per test and removes the labels via the api afterwards so the + * shared fixture pond does not accumulate them. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +type Ctx = Awaited>; + +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<{ id: string; slug: string }> { + const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } }); + return created.json(); +} + +async function createLabel( + context: Ctx, + pondId: string, + name: string, + parentId?: string, +): Promise<{ id: string }> { + const created = await context.request.post(`/api/v1/ponds/${pondId}/labels`, { + data: { name, ...(parentId ? { parentId } : {}) }, + }); + return created.json(); +} + +test('label lifecycle works from the pond settings UI', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const ts = Date.now(); + const root = `Alpha ${ts}`; + const renamed = `Alpha2 ${ts}`; + const child = `Sub ${ts}`; + + const page = await context.newPage(); + // Delete confirmations are window.confirm dialogs — accept them. + page.on('dialog', (dialog) => void dialog.accept()); + await page.goto(`/p/${pond.slug}/settings`); + + // Create a root label using only the keyboard (type + Enter submits the form). + const newInput = page.locator('.label-manager__new-root input'); + await newInput.fill(root); + await newInput.press('Enter'); + await expect(page.locator('.label-node__name', { hasText: root })).toBeVisible(); + + // Locate a row by its name span — NOT getByText, which would also match the + // move-dropdown