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:
161
apps/web/tests/e2e/common-items.test.ts
Normal file
161
apps/web/tests/e2e/common-items.test.ts
Normal 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.
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user