From 2f01eb9cb084feee9ac1ca005d0ffb33a573033d Mon Sep 17 00:00:00 2001
From: Oier Bravo Urtasun
Date: Tue, 19 May 2026 03:19:56 +0200
Subject: [PATCH] feat(fase-19): wire EmojiPicker into 4 integration sites +
EP-E specs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the four legacy hardcoded emoji arrays (settings avatar,
onboarding-create, CreateCollectiveModal, manage-collective rename) with
``. The shared component
delivers ~280 codepoints across 4 tabs instead of the previous 8–24
per surface, with consistent layout and behaviour.
Backwards-incompatible testid change on the manage page: the old
`collective-emoji-option-{e}` per-cell testid is gone — every cell now
lives under the picker's `emoji-picker-cell-{e}` namespace. Two existing
specs (onboarding O-02 + rename MC-01a) are updated to drive the new
tab → cell flow.
New e2e file `tests/e2e/emoji-picker.test.ts` covers EP-E-01..02:
- EP-E-01 — Ana on /settings picks 🍎 from "Comida"; DB row updates.
- EP-E-02 — Eva on /onboarding creates a collective with 🐱 from
"Animales"; sidebar reflects the choice.
Co-Authored-By: Claude Opus 4.7
---
.../components/CreateCollectiveModal.svelte | 23 +---
.../(app)/collective/manage/+page.svelte | 27 ++--
.../src/routes/(app)/settings/+page.svelte | 21 +---
apps/web/src/routes/onboarding/+page.svelte | 26 ++--
apps/web/tests/e2e/emoji-picker.test.ts | 116 ++++++++++++++++++
apps/web/tests/e2e/onboarding.test.ts | 4 +-
apps/web/tests/e2e/rename-collective.test.ts | 6 +-
7 files changed, 150 insertions(+), 73 deletions(-)
create mode 100644 apps/web/tests/e2e/emoji-picker.test.ts
diff --git a/apps/web/src/lib/components/CreateCollectiveModal.svelte b/apps/web/src/lib/components/CreateCollectiveModal.svelte
index e40c6a8..ad04277 100644
--- a/apps/web/src/lib/components/CreateCollectiveModal.svelte
+++ b/apps/web/src/lib/components/CreateCollectiveModal.svelte
@@ -3,15 +3,14 @@
import { getSupabase } from '$lib/supabase';
import { currentCollective, userCollectives } from '$lib/stores/collective';
import Spinner from './Spinner.svelte';
+ import EmojiPicker from './EmojiPicker.svelte';
import * as m from '$lib/paraglide/messages';
let { onClose }: { onClose: () => void } = $props();
- const EMOJI_OPTIONS = [
- '🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
- '🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
- '🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
- ];
+ // Fase 19 — emoji catalog moved into the shared `EmojiPicker`. The 24
+ // hardcoded city/house glyphs the old `EMOJI_OPTIONS` array shipped are
+ // covered by the new "objects" tab.
let name = $state('');
let emoji = $state('🏠');
@@ -73,19 +72,7 @@
{m.onboarding_collective_emoji()}
-
- {#each EMOJI_OPTIONS as e}
-
- {/each}
-
+ (emoji = e)} />
{#if error}
diff --git a/apps/web/src/routes/(app)/collective/manage/+page.svelte b/apps/web/src/routes/(app)/collective/manage/+page.svelte
index 719808c..b7cc8ba 100644
--- a/apps/web/src/routes/(app)/collective/manage/+page.svelte
+++ b/apps/web/src/routes/(app)/collective/manage/+page.svelte
@@ -5,6 +5,7 @@
import { currentUser } from '$lib/stores/auth';
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
import Avatar from '$lib/components/Avatar.svelte';
+ import EmojiPicker from '$lib/components/EmojiPicker.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
import { SECTION_KEYS } from '@colectivo/types';
@@ -174,11 +175,9 @@
let nameSaved = $state(false);
let emojiPickerOpen = $state(false);
- const EMOJI_OPTIONS = [
- '🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
- '🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
- '🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
- ];
+ // Fase 19 — emoji catalog moved into the shared `EmojiPicker` component.
+ // `selectEmoji` (admin-write path) is still local because it includes
+ // the closed-state toggle + the persistence round-trip.
// Keep nameDraft in sync with $currentCollective.
$effect(() => {
@@ -635,19 +634,11 @@
{#if emojiPickerOpen}
-
- {#each EMOJI_OPTIONS as e}
-
- {/each}
+
+ void selectEmoji(e)}
+ />
{/if}
diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte
index cc48546..2189c4b 100644
--- a/apps/web/src/routes/(app)/settings/+page.svelte
+++ b/apps/web/src/routes/(app)/settings/+page.svelte
@@ -7,6 +7,7 @@
import { logout } from '$lib/auth';
import Avatar from '$lib/components/Avatar.svelte';
import ImageCropper from '$lib/components/ImageCropper.svelte';
+ import EmojiPicker from '$lib/components/EmojiPicker.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
import TagChip from '$lib/components/TagChip.svelte';
@@ -51,12 +52,9 @@
let cropperFile = $state
(null);
let uploading = $state(false);
- const EMOJI_GRID = [
- '😀','😎','🥳','🤓','👻','🐱','🐶','🦊',
- '🐸','🦁','🐻','🐼','🐨','🐯','🦄','🐧',
- '🦋','🌈','⭐','🌙','☀️','🌊','🌴','🌺',
- '🍕','🍦','🎸','🎯','🚀','⚽','🎮','🎨'
- ];
+ // Fase 19 — emoji avatars expansion. The legacy `EMOJI_GRID` hardcoded
+ // array (8 faces + 24 others) is replaced by the tabbed `EmojiPicker`
+ // component which surfaces ~280 codepoints across 4 categories.
const keycloakAccountUrl = `${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/account`;
@@ -413,16 +411,7 @@
{#if avatarType === 'emoji'}
-
- {#each EMOJI_GRID as e}
-
- {/each}
-
+
{:else if avatarType === 'upload'}
-
- {#each EMOJI_OPTIONS as e}
-
- {/each}
-
+ (collectiveEmoji = e)}
+ />
{#if createError}
diff --git a/apps/web/tests/e2e/emoji-picker.test.ts b/apps/web/tests/e2e/emoji-picker.test.ts
new file mode 100644
index 0000000..0612acf
--- /dev/null
+++ b/apps/web/tests/e2e/emoji-picker.test.ts
@@ -0,0 +1,116 @@
+/**
+ * EP-E series — Fase 19.4.3.
+ *
+ * The avatar / collective-icon picker on /settings, /onboarding,
+ * /collective/manage and inside CreateCollectiveModal is the new
+ * `` component (4 tabs, ~280 codepoints). These specs
+ * drive the new control end-to-end on the two primary surfaces.
+ *
+ * EP-E-01 — Ana on /settings switches to the avatar=emoji mode, opens
+ * the picker, jumps to the "Comida" tab, picks 🍎, and the
+ * `users.avatar_emoji` row + the on-page Avatar both update.
+ * EP-E-02 — Eva creates her first collective on /onboarding using a
+ * cell from the "Animales" tab — verifies the cross-tab
+ * selection makes it into `collectives.emoji` and that the
+ * sidebar renders the chosen emoji.
+ *
+ * Cleanup: settings test restores Ana's avatar back to initials so
+ * downstream avatar specs don't see a stale 🍎.
+ */
+import { test, expect } from '@playwright/test';
+import { USERS } from '../fixtures/users.js';
+import { loginAs } from '../fixtures/login.js';
+import { resetEva } from '../fixtures/db.js';
+import { closePool, sql } from '@colectivo/test-utils';
+
+test.describe.configure({ mode: 'serial' });
+
+test.describe('EmojiPicker integration (Fase 19)', () => {
+ test.afterAll(async () => {
+ // Reset Ana's avatar back to initials so the rest of the suite (and
+ // avatar-related visuals) see the default.
+ await sql(
+ `UPDATE public.users SET avatar_type = 'initials', avatar_emoji = NULL WHERE id = $1`,
+ [USERS.ana.id]
+ );
+ await resetEva();
+ await closePool();
+ });
+
+ test('EP-E-01: /settings — Ana picks 🍎 from the "Comida" tab', async ({ page }) => {
+ await loginAs(page, USERS.ana);
+ await page.goto('/settings');
+
+ // Switch the avatar mode to "Emoji" so the picker mounts.
+ const emojiModeBtn = page.getByRole('button', { name: /Emoji/i }).first();
+ await expect(emojiModeBtn).toBeVisible({ timeout: 15_000 });
+ await emojiModeBtn.click();
+
+ // Picker root + tabs are visible.
+ const picker = page.getByTestId('emoji-picker');
+ await expect(picker).toBeVisible({ timeout: 5_000 });
+
+ // Jump to "Comida" tab and pick 🍎.
+ await page.getByTestId('emoji-picker-tab-food').click();
+ await expect(page.getByTestId('emoji-picker-grid')).toHaveAttribute(
+ 'data-category',
+ 'food'
+ );
+
+ await page.getByTestId('emoji-picker-cell-🍎').click();
+
+ // DB row reflects the change (poll because the UPDATE fires after
+ // onSelect lands in the Svelte runloop).
+ await expect
+ .poll(
+ async () => {
+ const r = await sql(
+ 'SELECT avatar_type, avatar_emoji FROM public.users WHERE id = $1',
+ [USERS.ana.id]
+ );
+ return r.rows[0] ?? null;
+ },
+ { timeout: 5_000 }
+ )
+ .toMatchObject({ avatar_type: 'emoji', avatar_emoji: '🍎' });
+ });
+
+ test('EP-E-02: /onboarding — Eva creates a collective with 🐱 from "Animales"', async ({
+ page
+ }) => {
+ await resetEva();
+ await loginAs(page, USERS.eva, { waitForCollective: false });
+ await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
+
+ const name = `Eva animals ${Date.now()}`;
+ await page.getByTestId('collective-name-input').fill(name);
+
+ // Open the picker (it's mounted inline on /onboarding, no toggle).
+ await expect(page.getByTestId('emoji-picker')).toBeVisible({ timeout: 5_000 });
+
+ // Switch to the "Animales" tab and pick 🐱.
+ await page.getByTestId('emoji-picker-tab-animals').click();
+ await expect(page.getByTestId('emoji-picker-grid')).toHaveAttribute(
+ 'data-category',
+ 'animals'
+ );
+ await page.getByTestId('emoji-picker-cell-🐱').click();
+
+ await page.getByTestId('collective-submit').click();
+ await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 });
+
+ const activeId = await page.evaluate(() => localStorage.getItem('activeCollectiveId'));
+ expect(activeId).toBeTruthy();
+ const res = await sql('SELECT name, emoji FROM public.collectives WHERE id = $1', [
+ activeId
+ ]);
+ expect(res.rows[0].name).toBe(name);
+ expect(res.rows[0].emoji).toBe('🐱');
+
+ // Sidebar reflects it.
+ const sidebar = page.getByTestId('desktop-sidebar');
+ await expect(sidebar.getByText('🐱', { exact: false })).toBeVisible({
+ timeout: 10_000
+ });
+ });
+});
diff --git a/apps/web/tests/e2e/onboarding.test.ts b/apps/web/tests/e2e/onboarding.test.ts
index 73abc2c..7277981 100644
--- a/apps/web/tests/e2e/onboarding.test.ts
+++ b/apps/web/tests/e2e/onboarding.test.ts
@@ -40,7 +40,9 @@ test.describe('Onboarding', () => {
const name = `Eva Home ${Date.now()}`;
await page.getByTestId('collective-name-input').fill(name);
- await page.getByRole('button', { name: '🏡', exact: true }).click();
+ // EmojiPicker (Fase 19) — 🏡 lives in the "objects" tab; default is "faces".
+ await page.getByTestId('emoji-picker-tab-objects').click();
+ await page.getByTestId('emoji-picker-cell-🏡').click();
await page.getByTestId('collective-submit').click();
// Landing page after creation.
diff --git a/apps/web/tests/e2e/rename-collective.test.ts b/apps/web/tests/e2e/rename-collective.test.ts
index 8f5597d..38cfe80 100644
--- a/apps/web/tests/e2e/rename-collective.test.ts
+++ b/apps/web/tests/e2e/rename-collective.test.ts
@@ -36,9 +36,11 @@ test.describe('Rename collective (CU-H07)', () => {
await nameInput.fill(newName);
await page.getByTestId('collective-name-save').click();
- // Pick a different emoji.
+ // Pick a different emoji from the new tabbed EmojiPicker (Fase 19).
+ // 🏡 lives in the "objects" tab; default is "faces".
await page.getByTestId('collective-emoji-toggle').click();
- await page.getByTestId('collective-emoji-option-🏡').click();
+ await page.getByTestId('emoji-picker-tab-objects').click();
+ await page.getByTestId('emoji-picker-cell-🏡').click();
// Reload and verify the inputs reflect the persisted value.
await page.reload();