From 5e2ba3a6d6aac9fd87e58e53e6d543c6fb17a230 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 02:07:16 +0200 Subject: [PATCH] feat(fase-10): CU-H07 rename collective + change emoji Admin gets an editable name input + emoji picker at the top of /collective/manage. Enter / blur / Save button all flush the PostgREST UPDATE; Esc reverts to the persisted value. The response payload feeds both $currentCollective and $userCollectives so the sidebar updates without reload. Members and guests see no editing affordances at all (the section is admin-gated; UPDATE policies were already in place from Fase 2a so no migration is needed). MC-01a: Ana renames + picks a new emoji, reload preserves both, DB reflects the change. MC-01b: Borja never sees the editable fields. Co-Authored-By: Claude Opus 4.7 --- .../(app)/collective/manage/+page.svelte | 137 +++++++++++++++++- apps/web/tests/e2e/rename-collective.test.ts | 65 +++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/e2e/rename-collective.test.ts diff --git a/apps/web/src/routes/(app)/collective/manage/+page.svelte b/apps/web/src/routes/(app)/collective/manage/+page.svelte index e2938c0..fc66cb6 100644 --- a/apps/web/src/routes/(app)/collective/manage/+page.svelte +++ b/apps/web/src/routes/(app)/collective/manage/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { getSupabase } from '$lib/supabase'; import { currentUser } from '$lib/stores/auth'; - import { currentCollective, collectiveMembers } from '$lib/stores/collective'; + import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective'; import Avatar from '$lib/components/Avatar.svelte'; import * as m from '$lib/paraglide/messages'; @@ -143,6 +143,75 @@ if (role === 'member') return m.role_member(); return m.role_guest(); } + + // ── Fase 10.2: rename + change emoji (admin only) ─────────────────────── + let nameDraft = $state(''); + let nameSaving = $state(false); + let nameSaved = $state(false); + let emojiPickerOpen = $state(false); + + const EMOJI_OPTIONS = [ + '🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨', + '🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼', + '🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵' + ]; + + // Keep nameDraft in sync with $currentCollective. + $effect(() => { + if ($currentCollective) nameDraft = $currentCollective.name; + }); + + async function saveCollectiveName() { + if (!$currentCollective || !isAdmin) return; + const trimmed = nameDraft.trim(); + if (!trimmed || trimmed === $currentCollective.name) return; + nameSaving = true; + const { data, error: updErr } = await getSupabase() + .from('collectives') + .update({ name: trimmed }) + .eq('id', $currentCollective.id) + .select('id, name, emoji, created_at') + .single(); + nameSaving = false; + if (updErr || !data) { + error = updErr?.message ?? 'Failed to save name.'; + return; + } + applyCollectivePatch(data as { id: string; name: string; emoji: string; created_at: string }); + nameSaved = true; + setTimeout(() => (nameSaved = false), 1500); + } + + function onNameKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + void saveCollectiveName(); + } else if (e.key === 'Escape') { + e.preventDefault(); + if ($currentCollective) nameDraft = $currentCollective.name; + } + } + + async function selectEmoji(emoji: string) { + emojiPickerOpen = false; + if (!$currentCollective || !isAdmin || emoji === $currentCollective.emoji) return; + const { data, error: updErr } = await getSupabase() + .from('collectives') + .update({ emoji }) + .eq('id', $currentCollective.id) + .select('id, name, emoji, created_at') + .single(); + if (updErr || !data) { + error = updErr?.message ?? 'Failed to save emoji.'; + return; + } + applyCollectivePatch(data as { id: string; name: string; emoji: string; created_at: string }); + } + + function applyCollectivePatch(c: { id: string; name: string; emoji: string; created_at: string }) { + currentCollective.set(c); + userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it))); + }
@@ -161,6 +230,72 @@

{error}

{/if} + + {#if isAdmin && $currentCollective} +
+
+ +
+ +
+ void saveCollectiveName()} + maxlength="60" + class="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" + /> + {#if nameSaving} + {m.settings_saving()} + {:else if nameSaved} + + {/if} +
+
+ +
+ {#if emojiPickerOpen} +
+ {#each EMOJI_OPTIONS as e} + + {/each} +
+ {/if} +
+ {/if} +

diff --git a/apps/web/tests/e2e/rename-collective.test.ts b/apps/web/tests/e2e/rename-collective.test.ts new file mode 100644 index 0000000..8f5597d --- /dev/null +++ b/apps/web/tests/e2e/rename-collective.test.ts @@ -0,0 +1,65 @@ +/** + * MC-01 (rescued from Fase 7) — /collective/manage rename + emoji change. + * + * Admin (Ana) edits name + emoji inline; the value persists across reload. + * Member (Borja) does not see the editable inputs. + * + * Cleanup: restore the seed name + emoji in afterAll so downstream suites + * that hard-match "Casa García-López" / "🏠" don't break. + */ +import { test, expect } from '@playwright/test'; +import { USERS } from '../fixtures/users.js'; +import { loginAs } from '../fixtures/login.js'; +import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Rename collective (CU-H07)', () => { + test.afterAll(async () => { + await sql(`UPDATE public.collectives SET name = $1, emoji = $2 WHERE id = $3`, [ + 'Casa García-López', + '🏠', + COLLECTIVE_ID + ]); + await closePool(); + }); + + test('MC-01a: admin renames + changes emoji; value persists across reload', async ({ + page + }) => { + await loginAs(page, USERS.ana); + await page.goto('/collective/manage'); + + const newName = `Casa renamed ${Date.now()}`; + const nameInput = page.getByTestId('collective-name-edit'); + await expect(nameInput).toBeVisible({ timeout: 15_000 }); + await nameInput.fill(newName); + await page.getByTestId('collective-name-save').click(); + + // Pick a different emoji. + await page.getByTestId('collective-emoji-toggle').click(); + await page.getByTestId('collective-emoji-option-🏡').click(); + + // Reload and verify the inputs reflect the persisted value. + await page.reload(); + await expect(page.getByTestId('collective-name-edit')).toHaveValue(newName, { + timeout: 15_000 + }); + + const row = await sql('SELECT name, emoji FROM public.collectives WHERE id = $1', [ + COLLECTIVE_ID + ]); + expect(row.rows[0].name).toBe(newName); + expect(row.rows[0].emoji).toBe('🏡'); + }); + + test('MC-01b: non-admin (Borja) does not see editable inputs', async ({ page }) => { + await loginAs(page, USERS.borja); + await page.goto('/collective/manage'); + + // Members list loads → page is hydrated; editable name input must be absent. + await expect(page.getByTestId(`member-row-${USERS.ana.id}`)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('collective-name-edit')).toHaveCount(0); + await expect(page.getByTestId('collective-emoji-toggle')).toHaveCount(0); + }); +});