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