feat(fase-19): wire EmojiPicker into 4 integration sites + EP-E specs
Replaces the four legacy hardcoded emoji arrays (settings avatar,
onboarding-create, CreateCollectiveModal, manage-collective rename) with
`<EmojiPicker selected={…} onSelect={…} />`. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,15 +3,14 @@
|
|||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||||
import Spinner from './Spinner.svelte';
|
import Spinner from './Spinner.svelte';
|
||||||
|
import EmojiPicker from './EmojiPicker.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
let { onClose }: { onClose: () => void } = $props();
|
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 name = $state('');
|
||||||
let emoji = $state('🏠');
|
let emoji = $state('🏠');
|
||||||
@@ -73,19 +72,7 @@
|
|||||||
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{m.onboarding_collective_emoji()}
|
{m.onboarding_collective_emoji()}
|
||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-8 gap-1">
|
<EmojiPicker selected={emoji} onSelect={(e) => (emoji = e)} />
|
||||||
{#each EMOJI_OPTIONS as e}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
|
||||||
{emoji === e
|
|
||||||
? 'bg-slate-900 dark:bg-slate-100'
|
|
||||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
|
||||||
onclick={() => (emoji = e)}
|
|
||||||
aria-label={e}
|
|
||||||
>{e}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { currentUser } from '$lib/stores/auth';
|
import { currentUser } from '$lib/stores/auth';
|
||||||
import { currentCollective, collectiveMembers, userCollectives } 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 EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||||
import Spinner from '$lib/components/Spinner.svelte';
|
import Spinner from '$lib/components/Spinner.svelte';
|
||||||
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
|
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
|
||||||
import { SECTION_KEYS } from '@colectivo/types';
|
import { SECTION_KEYS } from '@colectivo/types';
|
||||||
@@ -174,11 +175,9 @@
|
|||||||
let nameSaved = $state(false);
|
let nameSaved = $state(false);
|
||||||
let emojiPickerOpen = $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.
|
// Keep nameDraft in sync with $currentCollective.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -635,19 +634,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if emojiPickerOpen}
|
{#if emojiPickerOpen}
|
||||||
<div class="mt-3 grid grid-cols-8 gap-1 rounded-lg bg-surface-container-low p-3 dark:bg-surface-raised">
|
<div class="mt-3 rounded-lg bg-surface-container-low p-3 dark:bg-surface-raised">
|
||||||
{#each EMOJI_OPTIONS as e}
|
<EmojiPicker
|
||||||
<button
|
selected={$currentCollective.emoji}
|
||||||
type="button"
|
onSelect={(e) => void selectEmoji(e)}
|
||||||
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { logout } from '$lib/auth';
|
import { logout } from '$lib/auth';
|
||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
||||||
|
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
|
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
|
||||||
import TagChip from '$lib/components/TagChip.svelte';
|
import TagChip from '$lib/components/TagChip.svelte';
|
||||||
@@ -51,12 +52,9 @@
|
|||||||
let cropperFile = $state<File | null>(null);
|
let cropperFile = $state<File | null>(null);
|
||||||
let uploading = $state(false);
|
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`;
|
const keycloakAccountUrl = `${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/account`;
|
||||||
|
|
||||||
@@ -413,16 +411,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if avatarType === 'emoji'}
|
{#if avatarType === 'emoji'}
|
||||||
<div class="grid grid-cols-8 gap-1">
|
<EmojiPicker selected={avatarEmoji} onSelect={selectEmojiAvatar} />
|
||||||
{#each EMOJI_GRID as e}
|
|
||||||
<button
|
|
||||||
onclick={() => selectEmojiAvatar(e)}
|
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl
|
|
||||||
{avatarEmoji === e ? 'bg-slate-900 dark:bg-slate-100' : 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
|
||||||
aria-label={e}
|
|
||||||
>{e}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if avatarType === 'upload'}
|
{:else if avatarType === 'upload'}
|
||||||
<label
|
<label
|
||||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed
|
class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||||
|
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
type Tab = 'create' | 'join';
|
type Tab = 'create' | 'join';
|
||||||
@@ -14,11 +15,9 @@
|
|||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let createError = $state<string | null>(null);
|
let createError = $state<string | null>(null);
|
||||||
|
|
||||||
const EMOJI_OPTIONS = [
|
// Fase 19 — emoji catalog moved to the shared `EmojiPicker` component
|
||||||
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
|
// (4 tabs, ~280 codepoints). The legacy 24-item `EMOJI_OPTIONS` array
|
||||||
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
|
// is gone.
|
||||||
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Join via invite link
|
// Join via invite link
|
||||||
let inviteLink = $state('');
|
let inviteLink = $state('');
|
||||||
@@ -126,19 +125,10 @@
|
|||||||
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{m.onboarding_collective_emoji()}
|
{m.onboarding_collective_emoji()}
|
||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-8 gap-1">
|
<EmojiPicker
|
||||||
{#each EMOJI_OPTIONS as e}
|
selected={collectiveEmoji}
|
||||||
<button
|
onSelect={(e) => (collectiveEmoji = e)}
|
||||||
type="button"
|
/>
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
|
||||||
{collectiveEmoji === e
|
|
||||||
? 'bg-slate-900 dark:bg-slate-100'
|
|
||||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
|
||||||
onclick={() => (collectiveEmoji = e)}
|
|
||||||
aria-label={e}
|
|
||||||
>{e}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if createError}
|
{#if createError}
|
||||||
|
|||||||
116
apps/web/tests/e2e/emoji-picker.test.ts
Normal file
116
apps/web/tests/e2e/emoji-picker.test.ts
Normal file
@@ -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
|
||||||
|
* `<EmojiPicker>` 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,7 +40,9 @@ test.describe('Onboarding', () => {
|
|||||||
|
|
||||||
const name = `Eva Home ${Date.now()}`;
|
const name = `Eva Home ${Date.now()}`;
|
||||||
await page.getByTestId('collective-name-input').fill(name);
|
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();
|
await page.getByTestId('collective-submit').click();
|
||||||
|
|
||||||
// Landing page after creation.
|
// Landing page after creation.
|
||||||
|
|||||||
@@ -36,9 +36,11 @@ test.describe('Rename collective (CU-H07)', () => {
|
|||||||
await nameInput.fill(newName);
|
await nameInput.fill(newName);
|
||||||
await page.getByTestId('collective-name-save').click();
|
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-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.
|
// Reload and verify the inputs reflect the persisted value.
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|||||||
Reference in New Issue
Block a user