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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user