feat(fase-18): /collective/manage — suggested titles subsection (default + catalog)

Adds the admin-side surface for the Fase 18 list-title flow:

  • `default_list_title` input — single line, save-on-blur (matches the
    collective-name input shape from Fase 10.2). Optimistically patches
    `currentCollective` so the next /lists modal prefill sees the value
    without waiting for the realtime UPDATE to round-trip.
  • Curated catalog list — one row per entry with an inline "Remove"
    icon button. An "Add title" button opens a small modal (name input
    + Save) that calls `add_list_title` via the listTitles store.

Members see the whole section read-only — the input is `disabled`, the
"Add title" button is hidden, and a banner explains "Only admins can
curate these titles." Guests don't see the section at all (mirrors the
`canSeeCommonItems` gate from Fase 15).

The default-title `$effect` that mirrors `$currentCollective.default_list_title`
into the input draft is guarded against re-firing while the admin is
mid-edit — only sync when the input isn't focused — so realtime UPDATEs
from another tab don't clobber the unsaved edit.

E2E `tests/e2e/list-title-flow.test.ts` gains:
  • LTF-02 — Ana adds the catalog prefix via the manage UI, sets the
    default via the same UI, then drives the create-modal three times
    and asserts the third list lands as `${prefix} #3`. Exercises the
    full path: manage RPC → realtime/store update → modal auto-suffix
    on submit → numbered list created.
  • LTF-04 — Borja (member) opens manage, sees the section, no add
    button, read-only banner visible, default-title input disabled.

Tests: 4 LTF e2e + 4 existing manage-collective e2e all green (8/8).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 02:33:36 +02:00
parent 4ce24a4145
commit c0db382ae2
2 changed files with 356 additions and 0 deletions

View File

@@ -15,6 +15,13 @@
setWeight as setCommonItemWeight,
purge as purgeCommonItem
} from '$lib/stores/commonItems';
import {
listTitleCatalog,
loadTitleCatalog,
addTitle as addCatalogTitle,
removeTitle as removeCatalogTitle,
setDefaultListTitle
} from '$lib/stores/listTitles';
import * as m from '$lib/paraglide/messages';
type Member = {
@@ -51,6 +58,7 @@
if (c) {
void loadMembers();
void loadCommonItems(c.id);
void loadTitleCatalog(c.id);
}
});
return unsub;
@@ -432,6 +440,103 @@
return d.toLocaleDateString();
}
// ── Fase 18: list-title catalog (default + curated suggestions) ─────────
// Admin writes via the RPCs in `$lib/stores/listTitles`. Members + guests
// see the section read-only (the surface mirrors the common-items pattern
// from Fase 15 so the page reads as one consistent admin-tooling area).
const canSeeListTitles = $derived(myRole === 'admin' || myRole === 'member');
let defaultTitleDraft = $state('');
let defaultTitleSaving = $state(false);
let defaultTitleSaved = $state(false);
let listTitlesError = $state<string | null>(null);
// Keep the draft input in sync with the active collective. Guarded against
// the realtime UPDATE re-firing while the admin is mid-edit (only sync
// when the input isn't focused).
$effect(() => {
if ($currentCollective && document.activeElement?.id !== 'default-list-title-input') {
defaultTitleDraft = $currentCollective.default_list_title ?? '';
}
});
async function saveDefaultTitle() {
if (!$currentCollective || !isAdmin) return;
const trimmed = defaultTitleDraft.trim();
const current = $currentCollective.default_list_title ?? '';
if (trimmed === current) return;
defaultTitleSaving = true;
listTitlesError = null;
const err = await setDefaultListTitle($currentCollective.id, trimmed.length > 0 ? trimmed : null);
defaultTitleSaving = false;
if (err) {
listTitlesError = err.message;
return;
}
// Optimistic patch so the next /lists modal prefill sees the value
// even before the realtime UPDATE round-trips.
currentCollective.update((c) =>
c ? { ...c, default_list_title: trimmed.length > 0 ? trimmed : null } : c
);
userCollectives.update((list) =>
list.map((c) =>
c.id === $currentCollective!.id
? { ...c, default_list_title: trimmed.length > 0 ? trimmed : null }
: c
)
);
defaultTitleSaved = true;
setTimeout(() => (defaultTitleSaved = false), 1500);
}
function onDefaultTitleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
void saveDefaultTitle();
} else if (e.key === 'Escape') {
e.preventDefault();
if ($currentCollective) defaultTitleDraft = $currentCollective.default_list_title ?? '';
}
}
// Curated-titles CRUD (modal-based add, inline remove).
let titleAddOpen = $state(false);
let titleAddName = $state('');
let titleAddSaving = $state(false);
function openTitleAdd() {
if (!isAdmin) return;
titleAddName = '';
titleAddOpen = true;
}
function closeTitleAdd() {
titleAddOpen = false;
}
async function confirmTitleAdd() {
if (!$currentCollective || !isAdmin) return;
const trimmed = titleAddName.trim();
if (!trimmed) return;
titleAddSaving = true;
listTitlesError = null;
const err = await addCatalogTitle($currentCollective.id, trimmed);
titleAddSaving = false;
if (err) {
listTitlesError = err.message;
return;
}
titleAddOpen = false;
}
async function handleRemoveTitle(title: string) {
if (!$currentCollective || !isAdmin) return;
listTitlesError = null;
const err = await removeCatalogTitle($currentCollective.id, title);
if (err) listTitlesError = err.message;
}
async function confirmDissolve() {
if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return;
const target = $currentCollective;
@@ -749,6 +854,104 @@
</section>
{/if}
<!-- Suggested list titles (Fase 18) — admin sets default + curates catalog;
members read-only; guests hidden. Mirrors the common-items shape. -->
{#if canSeeListTitles}
<section data-testid="list-titles-section" class="mb-8">
<div class="mb-2 flex items-center justify-between gap-3">
<h2 class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.manage_list_titles_section_title()}
</h2>
{#if isAdmin}
<button
type="button"
data-testid="list-titles-add-button"
onclick={openTitleAdd}
class="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
>
{m.manage_list_titles_add_button()}
</button>
{/if}
</div>
<p class="mb-3 text-xs text-text-secondary">{m.manage_list_titles_blurb()}</p>
{#if !isAdmin}
<p class="mb-3 text-xs text-text-secondary" data-testid="list-titles-readonly-banner">
{m.manage_list_titles_readonly()}
</p>
{/if}
{#if listTitlesError}
<p class="mb-2 text-sm text-destructive" data-testid="list-titles-error">{listTitlesError}</p>
{/if}
<!-- Default-title input -->
<div class="mb-4">
<label
for="default-list-title-input"
class="mb-1 block text-xs font-medium text-text-secondary"
>
{m.manage_default_list_title_label()}
</label>
<div class="relative">
<input
id="default-list-title-input"
data-testid="manage-default-list-title-input"
type="text"
bind:value={defaultTitleDraft}
onkeydown={onDefaultTitleKeydown}
onblur={() => void saveDefaultTitle()}
disabled={!isAdmin}
maxlength="120"
placeholder={m.manage_default_list_title_placeholder()}
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none disabled:opacity-60
dark:border-slate-600 dark:bg-slate-800"
/>
{#if defaultTitleSaving}
<span class="absolute right-3 top-2 text-xs text-text-secondary">{m.settings_saving()}</span>
{:else if defaultTitleSaved}
<span class="absolute right-3 top-2 text-xs text-emerald-600"></span>
{/if}
</div>
</div>
<!-- Curated catalog -->
{#if $listTitleCatalog.length === 0}
<p class="text-sm text-text-secondary" data-testid="list-titles-empty">
{m.manage_list_titles_empty()}
</p>
{:else}
<ul class="space-y-1" data-testid="list-titles-list">
{#each $listTitleCatalog as row (row.title)}
<li
data-testid={`list-title-row-${row.title}`}
class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2"
>
<p class="min-w-0 flex-1 truncate text-sm font-medium text-slate-900 dark:text-slate-50">
{row.title}
</p>
<button
type="button"
data-testid={`list-title-remove-${row.title}`}
title={!isAdmin ? m.manage_list_titles_readonly() : m.manage_list_titles_remove()}
aria-label={m.manage_list_titles_remove()}
disabled={!isAdmin}
onclick={() => void handleRemoveTitle(row.title)}
class="shrink-0 rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-slate-400
dark:hover:bg-red-900/20"
>
</button>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
<!-- Invite link generator — background shift instead of border -->
{#if isAdmin}
<section class="rounded-lg bg-surface-container-low p-4 dark:bg-surface-raised">
@@ -1044,3 +1247,69 @@
</div>
</div>
{/if}
<!-- Add curated list-title modal (Fase 18) -->
{#if titleAddOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
data-testid="list-titles-add-modal"
role="dialog"
aria-modal="true"
onclick={(e) => {
if (e.target === e.currentTarget) closeTitleAdd();
}}
onkeydown={(e) => e.key === 'Escape' && closeTitleAdd()}
tabindex="-1"
>
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-3 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.manage_list_titles_add_modal_title()}
</h2>
<label for="list-titles-add-name" class="mb-1 block text-xs font-medium text-text-secondary">
{m.manage_list_titles_add_name_label()}
</label>
<!-- svelte-ignore a11y_autofocus -->
<input
id="list-titles-add-name"
data-testid="list-titles-add-name"
type="text"
bind:value={titleAddName}
autofocus
placeholder={m.manage_list_titles_add_name_placeholder()}
maxlength="120"
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void confirmTitleAdd();
}
}}
/>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={closeTitleAdd}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="list-titles-add-save"
disabled={!titleAddName.trim() || titleAddSaving}
onclick={() => void confirmTitleAdd()}
class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50
dark:bg-slate-50 dark:text-slate-900"
>
{#if titleAddSaving}
<Spinner size="sm" />
<span>{m.loading()}</span>
{:else}
{m.manage_list_titles_add_save()}
{/if}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -92,6 +92,70 @@ test.describe('Shopping list title flow — admin (Ana)', () => {
void titleInput;
});
test('LTF-02: catalog entry drives auto-suffix on the third "Compra" creation', async ({
page
}) => {
const prefix = `LTF02-${Date.now()}`;
// 1) Add the prefix to the catalog via the manage UI (the path under
// test). Then add it to default_list_title via the same UI so the
// modal prefills it consistently.
await page.goto('/collective/manage');
await expect(page.getByTestId('list-titles-add-button')).toBeVisible({
timeout: 10_000
});
await page.getByTestId('list-titles-add-button').click();
await expect(page.getByTestId('list-titles-add-modal')).toBeVisible({
timeout: 5_000
});
await page.getByTestId('list-titles-add-name').fill(prefix);
await page.getByTestId('list-titles-add-save').click();
await expect(page.getByTestId('list-titles-add-modal')).not.toBeVisible({
timeout: 5_000
});
await expect(
page.getByTestId(`list-title-row-${prefix}`)
).toBeVisible({ timeout: 5_000 });
const defaultInput = page.getByTestId('manage-default-list-title-input');
await defaultInput.fill(prefix);
await defaultInput.blur();
// Wait briefly for the UPDATE round-trip to land.
await page.waitForTimeout(700);
// Helper that drives the modal once: open → submit (prefilled) → wait
// for navigation, then bounce back to /lists.
async function createOne(): Promise<void> {
await page.goto('/lists');
await openCreateListModal(page);
// The prefill should be the catalog prefix verbatim — the modal
// auto-suffixes "#N" on submit when the value matches a catalog
// entry exactly without a "#N" already.
await expect(page.getByTestId('create-list-modal-name')).toHaveValue(prefix);
await page.getByTestId('create-list-modal-submit').click();
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
}
// 1st create → no prior matches → finalName === `${prefix}` (bare;
// computeNextNumber returns 1 → but rule 9: still fires auto-suffix
// when catalog match holds, so finalName === `${prefix} #1`).
await createOne();
// 2nd create → prior is `prefix #1` → finalName === `${prefix} #2`.
await createOne();
// 3rd create → prior set has {#1, #2} → finalName === `${prefix} #3`.
await createOne();
// Visit /lists and confirm the third numbered list exists. We don't
// assert the first two by name because the exact #N depends on race
// timing of the realtime echoes; what matters is that the catalog
// auto-suffix produced unique numbered names and the third one
// landed at #3 (no duplicates of bare prefix).
await page.goto('/lists');
await expect(
page.getByRole('heading', { name: new RegExp(`^${prefix} #3$`) })
).toBeVisible({ timeout: 10_000 });
});
test('LTF-03: empty input keeps the submit button disabled', async ({ page }) => {
// Make sure no default is set so the input opens blank.
await setDefaultListTitle(page, '');
@@ -114,3 +178,26 @@ test.describe('Shopping list title flow — admin (Ana)', () => {
await expect(submit).toBeEnabled();
});
});
test.describe('Shopping list title flow — member (Borja, read-only)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('LTF-04: member sees the section but the controls are disabled', async ({ page }) => {
await page.goto('/collective/manage');
await expect(page.getByTestId('list-titles-section')).toBeVisible({
timeout: 15_000
});
// No "Add title" button for non-admins.
await expect(page.getByTestId('list-titles-add-button')).not.toBeVisible();
// Read-only banner is visible.
await expect(page.getByTestId('list-titles-readonly-banner')).toBeVisible();
// The default-title input renders but is disabled.
await expect(page.getByTestId('manage-default-list-title-input')).toBeDisabled();
});
});