Remove the sticky bottom create input from /lists. The masthead now hosts
a primary "New list" button next to the Trash icon, styled identically to
the "New note" button on /notes. Click → createList with an empty name →
goto /lists/[id], where a prominent editable title input (above To buy /
Checked) autosaves on blur / input with a 500 ms debounce.
Changes
apps/web/src/routes/(app)/lists/+page.svelte
- remove `newListName`, `nameInput`, `handleKeydown`
- remove the absolute-positioned sticky bottom block
- handleCreate now creates with name = '' and navigates to the detail
- actions snippet gains the primary New list button; Trash button
demoted to ghost icon
apps/web/src/routes/(app)/lists/[id]/+page.svelte
- new listName / scheduleNameSave / flushNameSave state (mirrors the
/notes editor pattern)
- big editable title <input> at top of content, autosaves on blur
via renameList
- contextual header h1 trimmed to a secondary caption-size preview
on mobile (so the user still sees the list name at the top)
- flushNameSave on component destroy so unsaved renames commit
apps/web/messages/{en,es}.json: lists_new_list = "New list"/"Nueva lista"
Tests
apps/web/tests/e2e/lists.test.ts: createList helper now clicks the
button, fills the title input, blurs, waits for autosave, and goes
back to /lists to present the same "list-is-on-the-overview" state
that C-11 / C-12 expect. C-07 picks up the reload check.
apps/web/tests/e2e/session.test.ts: S-03 mirrors the new flow for its
fresh-list setup.
Verification
just test-e2e → 40 passed, 2 skipped (swipe-delete .skip remains).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
135 lines
5.1 KiB
TypeScript
135 lines
5.1 KiB
TypeScript
/**
|
|
* C-series (UI): Shopping list CRUD flows — create, complete, trash.
|
|
*
|
|
* Live Keycloak login per test (no cached storageState). See fixtures/login.ts
|
|
* and the comment in $lib/supabase for why the Supabase client uses a
|
|
* pass-through lock.
|
|
*/
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
import { USERS } from '../fixtures/users.js';
|
|
import { loginAs } from '../fixtures/login.js';
|
|
|
|
const PLACEHOLDER_NEW_LIST = /weekly shop|compra semanal/i;
|
|
|
|
/** Heading for a list — may render as h2 (featured card) or h3 (grid item). */
|
|
function listHeading(page: Page, name: string) {
|
|
return page.getByRole('heading', { name: new RegExp(`^${name}$`) });
|
|
}
|
|
|
|
/**
|
|
* The action menu button inside the card for a specific list. The DOM has one
|
|
* "List actions" button per active card, so we scope by the card wrapper
|
|
* (ancestor of the heading) — this avoids the strict-mode ambiguity of picking
|
|
* from many grid siblings.
|
|
*/
|
|
function cardActionsButton(page: Page, name: string) {
|
|
// The card wrapper is the `<div class="relative group">` two levels above
|
|
// the `<h3>` (or one level above `<h2>` for featured). An xpath that walks
|
|
// up until it finds the button gives us a stable selector.
|
|
return page
|
|
.locator(
|
|
`xpath=//h2[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]` +
|
|
` | //h3[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]`
|
|
)
|
|
.first()
|
|
.getByRole('button', { name: /list actions|acciones de lista/i });
|
|
}
|
|
|
|
async function gotoListsClean(page: Page) {
|
|
await page.goto('/lists');
|
|
// The masthead "New list" button is the anchor now (replaces the sticky input).
|
|
await expect(
|
|
page.getByRole('button', { name: /new list|nueva lista/i })
|
|
).toBeVisible({ timeout: 15_000 });
|
|
}
|
|
|
|
async function createList(page: Page, name: string) {
|
|
await page.getByRole('button', { name: /new list|nueva lista/i }).click();
|
|
// Lands on /lists/[id]; the editable title input carries the placeholder.
|
|
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
|
|
const titleInput = page.getByPlaceholder(PLACEHOLDER_NEW_LIST);
|
|
await expect(titleInput).toBeVisible({ timeout: 10_000 });
|
|
await titleInput.fill(name);
|
|
// Blur + wait for the 500 ms autosave debounce + buffer.
|
|
await titleInput.blur();
|
|
await page.waitForTimeout(900);
|
|
// Navigate back to /lists to give callers a consistent starting state.
|
|
await page.goto('/lists');
|
|
await expect(listHeading(page, name)).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
test.describe('Shopping lists — member (Borja)', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page, USERS.borja);
|
|
});
|
|
|
|
test('C-07: create a new shopping list', async ({ page }) => {
|
|
await gotoListsClean(page);
|
|
|
|
const listName = `E2E list ${Date.now()}`;
|
|
await createList(page, listName);
|
|
|
|
// Reload to prove the list persists server-side (not just optimistic).
|
|
await page.reload();
|
|
await expect(listHeading(page, listName)).toBeVisible({ timeout: 15_000 });
|
|
});
|
|
|
|
test('C-11: soft-delete a list — it moves to trash', async ({ page }) => {
|
|
await gotoListsClean(page);
|
|
|
|
const listName = `Trash me ${Date.now()}`;
|
|
await createList(page, listName);
|
|
|
|
// Scope the menu button to the card containing our new heading — it's
|
|
// the nearest ancestor that also has a List actions button.
|
|
await cardActionsButton(page, listName).click();
|
|
await page.getByRole('button', { name: /move to trash|enviar a la papelera/i }).click();
|
|
|
|
await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 });
|
|
|
|
// It shows up under the Trash drawer.
|
|
await page.getByRole('button', { name: /^trash$|^papelera$/i }).first().click();
|
|
await expect(page.getByText(listName)).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('C-12: archive a list — it moves out of the active grid', async ({ page }) => {
|
|
await gotoListsClean(page);
|
|
|
|
const listName = `Archive me ${Date.now()}`;
|
|
await createList(page, listName);
|
|
|
|
await cardActionsButton(page, listName).click();
|
|
await page.getByRole('button', { name: /^archive$|^archivar$/i }).click();
|
|
|
|
// Active grid no longer shows the archived list.
|
|
await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Shopping lists — guest (David, read-only)', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page, USERS.david);
|
|
});
|
|
|
|
test('C-13: guest sees the seed list (read-only)', async ({ page }) => {
|
|
await page.goto('/lists');
|
|
await expect(
|
|
page.getByRole('button', { name: /Casa García-López/ })
|
|
).toBeVisible({ timeout: 15_000 });
|
|
|
|
await expect(listHeading(page, 'Weekly shop')).toBeVisible({ timeout: 15_000 });
|
|
|
|
// If the create-list input is even rendered for guests, typing into it
|
|
// and pressing Enter must NOT create a committed list (RLS blocks the
|
|
// insert; the optimistic row rolls back).
|
|
const input = page.getByPlaceholder(PLACEHOLDER_NEW_LIST);
|
|
if (await input.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
|
const name = `David sneaky ${Date.now()}`;
|
|
await input.fill(name);
|
|
await input.press('Enter');
|
|
await page.waitForTimeout(1_500);
|
|
await expect(listHeading(page, name)).not.toBeVisible({ timeout: 2_000 });
|
|
}
|
|
});
|
|
});
|