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 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 02:07:16 +02:00
parent 2f0847a5a3
commit 5e2ba3a6d6
2 changed files with 201 additions and 1 deletions

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase'; import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth'; 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 Avatar from '$lib/components/Avatar.svelte';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
@@ -143,6 +143,75 @@
if (role === 'member') return m.role_member(); if (role === 'member') return m.role_member();
return m.role_guest(); 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)));
}
</script> </script>
<div class="flex flex-1 flex-col overflow-hidden"> <div class="flex flex-1 flex-col overflow-hidden">
@@ -161,6 +230,72 @@
<p class="mb-4 text-sm text-red-600">{error}</p> <p class="mb-4 text-sm text-red-600">{error}</p>
{/if} {/if}
<!-- Collective identity (Fase 10.2) — admin only -->
{#if isAdmin && $currentCollective}
<section class="mb-8">
<div class="relative flex items-center gap-3">
<button
type="button"
data-testid="collective-emoji-toggle"
onclick={() => (emojiPickerOpen = !emojiPickerOpen)}
class="flex h-12 w-12 items-center justify-center rounded-lg bg-surface-container-low text-3xl
hover:bg-black/5 dark:bg-surface-raised dark:hover:bg-white/5"
aria-label={m.manage_collective_emoji_label()}
>
{$currentCollective.emoji}
</button>
<div class="flex-1">
<label for="collective-name-edit" class="mb-1 block text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.manage_collective_name_label()}
</label>
<div class="relative">
<input
id="collective-name-edit"
data-testid="collective-name-edit"
type="text"
bind:value={nameDraft}
onkeydown={onNameKeydown}
onblur={() => 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}
<span class="absolute right-3 top-2 text-xs text-text-secondary">{m.settings_saving()}</span>
{:else if nameSaved}
<span class="absolute right-3 top-2 text-xs text-emerald-600"></span>
{/if}
</div>
</div>
<button
type="button"
data-testid="collective-name-save"
onclick={() => void saveCollectiveName()}
class="self-end rounded-md bg-slate-900 px-3 py-2 text-xs font-semibold text-white hover:opacity-90
dark:bg-slate-50 dark:text-slate-900"
>
{m.manage_collective_save()}
</button>
</div>
{#if emojiPickerOpen}
<div class="mt-3 grid grid-cols-8 gap-1 rounded-lg bg-surface-container-low p-3 dark:bg-surface-raised">
{#each EMOJI_OPTIONS as e}
<button
type="button"
data-testid="collective-emoji-option-{e}"
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
{$currentCollective.emoji === e
? 'bg-slate-900 dark:bg-slate-100'
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
onclick={() => void selectEmoji(e)}
aria-label={e}
>{e}</button>
{/each}
</div>
{/if}
</section>
{/if}
<!-- Members list — no dividers, vertical padding creates separation --> <!-- Members list — no dividers, vertical padding creates separation -->
<section class="mb-8"> <section class="mb-8">
<h2 class="mb-4 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary"> <h2 class="mb-4 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">

View File

@@ -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);
});
});