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>
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
/**
|
|
* 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.
|
|
});
|
|
});
|