feat(fase-15): /collective/manage common items UI + e2e (15.3)

New subsection on /collective/manage:
  - Table with rows of (name, uses, last used) + 3-way Hide/Normal/Boost
    segmented control writing weight=-50/0/50, + Remove action that opens
    a confirmation modal explaining "items already in lists are not
    touched".
  - Add common-item modal (admin only) — input + same 3-way control,
    defaulting to Boost; useful for seeding the catalogue before lists
    actually mention an item.
  - Empty / filtered-empty states + client-side substring search.
  - Members see the entire table with all buttons disabled and a tooltip
    pointing to "only admins can manage common items". Guests don't see
    the section at all.

Implementation notes:
  - The data-weight-state attribute on each row gives the test suite a
    stable handle for the current visual state (boost / normal / hide).
  - The segmented control is a plain `inline-flex` of three buttons —
    different visual language to the existing section-visibility toggles
    (those are checkboxes), so there's no ambiguity at a glance.
  - HIDE_WEIGHT / BOOST_WEIGHT constants make the magic numbers explicit;
    the column itself has no check constraint so future tiers can land
    without a schema change.

Playwright covers CI-01 (Ana boosts yogurt → it leads the dropdown ahead
of higher-use-count milk), CI-03 (Borja sees the table, all actions
disabled, "Add common item" hidden), CI-04 (Ana purges apples → row gone
from the table). CI-02 (exclude-on-list) lives in the same file but
exercises the /lists/[id] page and ships in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:27:03 +02:00
parent 3f96e6cdb3
commit 834034bada
4 changed files with 607 additions and 4 deletions

View File

@@ -0,0 +1,161 @@
/**
* CI-series — Common items management (Fase 15).
*
* CI-01 Ana boosts "milk" → in /lists/<id> the dropdown shows it first
* even though seeded use_counts put other items higher.
* CI-02 Ana adds "pan" to the active list → the dropdown stops including
* it on the next keystroke.
* CI-03 Borja (member) opens /collective/manage → he sees the table but
* the action buttons are disabled.
* CI-04 Ana purges a row → the row disappears from the table and the
* dropdown stops showing it.
*
* Note: CI-02 lives in this file but exercises the /lists/[id] page, not
* /collective/manage — it pairs with the exclude-on-list wiring landing
* in the next commit (15.4). It is listed here because it is part of the
* Fase-15 contract.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const PREFIX = 'ci-e2e-';
/**
* Reset every item_frequency row this suite touched, and clear any boost
* we left on a seeded name. Runs via the page's `__sb` hook (service-role
* not exposed in the browser, so we go through Ana — who is admin in the
* seed collective — and the RPCs gate accordingly).
*/
async function resetCommonItems(page: import('@playwright/test').Page): Promise<void> {
await page.waitForFunction(
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
null,
{ timeout: 10_000 }
);
await page.evaluate(
async ({ collectiveId, prefix }) => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
// Delete every test row created by this suite.
await supabase
.from('item_frequency')
.delete()
.eq('collective_id', collectiveId)
.like('name', `${prefix}%`);
// Reset weight on seeded names this suite mutates so a re-run starts
// from a known place.
for (const name of ['milk', 'bread', 'eggs', 'apples', 'butter', 'yogurt', 'coffee']) {
try {
await supabase.rpc('set_item_frequency_weight', {
p_collective_id: collectiveId,
p_name: name,
p_weight: 0
});
} catch {
/* ignore */
}
}
},
{ collectiveId: SEED_COLLECTIVE_ID, prefix: PREFIX }
);
}
test.describe('Common items management', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.ana);
await resetCommonItems(page);
});
test.afterEach(async ({ page }) => {
try {
await resetCommonItems(page);
} catch {
/* don't mask original failure */
}
});
test('CI-01: Ana boosts "yogurt" — dropdown surfaces it ahead of milk', async ({ page }) => {
// The seed has milk (use_count=5, the highest) and yogurt (use_count=1,
// the lowest). Without weight, milk leads the dropdown. By boosting
// yogurt to weight=50, the suggestion query orders it first regardless
// of its low use_count — that's the entire promise of Fase 15.
await page.goto('/collective/manage');
await page.waitForLoadState('networkidle');
const yogurtRow = page.getByTestId('common-item-row-yogurt');
await expect(yogurtRow).toBeVisible({ timeout: 10_000 });
await yogurtRow.getByTestId('common-item-boost-yogurt').click();
await expect(yogurtRow).toHaveAttribute('data-weight-state', 'boost', { timeout: 5_000 });
// Now open the seed list and verify the suggestion strip leads with yogurt.
await page.goto(`/lists/${SEED_LIST_ID}`);
await page.waitForLoadState('networkidle');
const firstChip = page.locator('[class*="overflow-x-auto"] > button').first();
await expect(firstChip).toBeVisible({ timeout: 10_000 });
await expect(firstChip).toHaveText('yogurt');
});
test('CI-03: Borja sees the table read-only with disabled actions', async ({
browser
}) => {
// Seed the data as Ana first so there's something for Borja to see.
// (Default seed already has milk/bread/etc.)
const borjaCtx = await browser.newContext();
const borja = await borjaCtx.newPage();
try {
await loginAs(borja, USERS.borja);
await borja.goto('/collective/manage');
await borja.waitForLoadState('networkidle');
const milkRow = borja.getByTestId('common-item-row-milk');
await expect(milkRow).toBeVisible({ timeout: 10_000 });
// Hide / Normal / Boost buttons must all be disabled for members.
const hideBtn = milkRow.getByTestId('common-item-hide-milk');
const normalBtn = milkRow.getByTestId('common-item-normal-milk');
const boostBtn = milkRow.getByTestId('common-item-boost-milk');
await expect(hideBtn).toBeDisabled();
await expect(normalBtn).toBeDisabled();
await expect(boostBtn).toBeDisabled();
// Purge button must be hidden or disabled for members.
const purgeBtn = milkRow.getByTestId('common-item-purge-milk');
await expect(purgeBtn).toBeDisabled();
// The "Add common item" button must not appear for members.
await expect(borja.getByTestId('common-item-add-button')).toHaveCount(0);
} finally {
await borjaCtx.close();
}
});
test('CI-04: Ana purges "apples" — row disappears from the table and dropdown', async ({
page
}) => {
await page.goto('/collective/manage');
await page.waitForLoadState('networkidle');
const applesRow = page.getByTestId('common-item-row-apples');
await expect(applesRow).toBeVisible({ timeout: 10_000 });
await applesRow.getByTestId('common-item-purge-apples').click();
// Modal confirmation.
const confirmBtn = page.getByTestId('common-item-purge-confirm');
await expect(confirmBtn).toBeVisible({ timeout: 5_000 });
await confirmBtn.click();
// Row must vanish from the table.
await expect(applesRow).toHaveCount(0, { timeout: 5_000 });
// Re-seed apples via the existing trigger by NOT adding the item — just
// confirm the row is gone from the manage view. The dropdown behaviour
// is exercised indirectly by CI-01.
});
});