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:
@@ -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">
|
||||||
|
|||||||
65
apps/web/tests/e2e/rename-collective.test.ts
Normal file
65
apps/web/tests/e2e/rename-collective.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user