From 10415bfca8e734760e196f53d74d756a310cf8b8 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 02:25:11 +0200 Subject: [PATCH] feat(fase-10): sidebar "+ New collective" entry + modal (10.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "+ New collective" entry at the bottom of the sidebar collective-switcher dropdown (and the mobile drawer for parity). Clicking it opens a CreateCollectiveModal that reuses the same onboarding form — name + emoji grid — and calls the existing create_collective() RPC (migration 012). On success, the new collective is appended to $userCollectives, set active in $currentCollective, persisted to localStorage, and the user is sent to /lists. Side effects: - Switcher now opens even with a single collective (previously gated on >1) so the entry is always reachable; the chevron is always shown. - DesktopSidebar gets a data-testid on the switcher button so the Playwright suite can target it without text matching. O-04 (rescued from Fase 7): Eva creates her first collective via onboarding, then opens the switcher and creates a second one without leaving /lists; both collectives end up in the switcher and the DB. Co-Authored-By: Claude Opus 4.7 --- .../components/CreateCollectiveModal.svelte | 110 ++++++++++++++++++ .../components/layout/DesktopSidebar.svelte | 29 ++++- .../lib/components/layout/MobileDrawer.svelte | 22 +++- .../e2e/sidebar-create-collective.test.ts | 65 +++++++++++ 4 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/components/CreateCollectiveModal.svelte create mode 100644 apps/web/tests/e2e/sidebar-create-collective.test.ts diff --git a/apps/web/src/lib/components/CreateCollectiveModal.svelte b/apps/web/src/lib/components/CreateCollectiveModal.svelte new file mode 100644 index 0000000..e2b656a --- /dev/null +++ b/apps/web/src/lib/components/CreateCollectiveModal.svelte @@ -0,0 +1,110 @@ + + +
+
+

+ {m.create_collective_modal_title()} +

+
{ e.preventDefault(); void submit(); }} class="space-y-4"> +
+ + +
+ +
+

+ {m.onboarding_collective_emoji()} +

+
+ {#each EMOJI_OPTIONS as e} + + {/each} +
+
+ + {#if error} +

{error}

+ {/if} + +
+ + +
+
+
+
diff --git a/apps/web/src/lib/components/layout/DesktopSidebar.svelte b/apps/web/src/lib/components/layout/DesktopSidebar.svelte index cbbd88d..c113c6e 100644 --- a/apps/web/src/lib/components/layout/DesktopSidebar.svelte +++ b/apps/web/src/lib/components/layout/DesktopSidebar.svelte @@ -3,8 +3,9 @@ import { displayName } from '$lib/stores/auth'; import { currentCollective, userCollectives } from '$lib/stores/collective'; import Avatar from '$lib/components/Avatar.svelte'; + import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte'; import * as m from '$lib/paraglide/messages'; - import { ShoppingCart, CheckSquare, FileText, Search, Settings } from 'lucide-svelte'; + import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus } from 'lucide-svelte'; const navItems = [ { href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart }, @@ -14,6 +15,7 @@ ]; let switcherOpen = $state(false); + let showCreate = $state(false); const activePath = $derived($page.url.pathname); @@ -25,6 +27,11 @@ switcherOpen = false; } } + + function openCreateCollective() { + switcherOpen = false; + showCreate = true; + } + +{#if showCreate} + (showCreate = false)} /> +{/if} diff --git a/apps/web/src/lib/components/layout/MobileDrawer.svelte b/apps/web/src/lib/components/layout/MobileDrawer.svelte index 068dbf0..87907c3 100644 --- a/apps/web/src/lib/components/layout/MobileDrawer.svelte +++ b/apps/web/src/lib/components/layout/MobileDrawer.svelte @@ -2,9 +2,12 @@ import { currentCollective, userCollectives } from '$lib/stores/collective'; import { displayName } from '$lib/stores/auth'; import Avatar from '$lib/components/Avatar.svelte'; + import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte'; import { logout } from '$lib/auth'; import * as m from '$lib/paraglide/messages'; - import { Settings, Users, LogOut, X } from 'lucide-svelte'; + import { Settings, Users, LogOut, X, Plus } from 'lucide-svelte'; + + let showCreate = $state(false); let { open = $bindable(false) }: { open?: boolean } = $props(); @@ -65,10 +68,21 @@ {/each} + {m.manage_title()} @@ -106,3 +120,7 @@ {/if} + +{#if showCreate} + (showCreate = false)} /> +{/if} diff --git a/apps/web/tests/e2e/sidebar-create-collective.test.ts b/apps/web/tests/e2e/sidebar-create-collective.test.ts new file mode 100644 index 0000000..cd5f82b --- /dev/null +++ b/apps/web/tests/e2e/sidebar-create-collective.test.ts @@ -0,0 +1,65 @@ +/** + * O-04 (rescued from Fase 7) — sidebar "+ New collective" entry (Fase 10.6). + * + * Eva starts onboarded with one collective; she opens the sidebar switcher, + * clicks "+ New collective", fills the modal, and ends up on /lists with + * both collectives visible in the switcher. + */ +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('Sidebar create collective (CU-H02 follow-up)', () => { + test.beforeEach(async () => { + await resetEva(); + }); + + test.afterAll(async () => { + await resetEva(); + await closePool(); + }); + + test('O-04: Eva with one collective creates a second from the sidebar', async ({ page }) => { + // Onboard Eva with a first collective so the switcher renders. + const firstName = `Eva First ${Date.now()}`; + await loginAs(page, USERS.eva, { waitForCollective: false }); + await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 }); + await page.getByTestId('collective-name-input').fill(firstName); + await page.getByTestId('collective-submit').click(); + await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 }); + + // Open the sidebar switcher and pick "+ New collective". + await page.getByTestId('sidebar-collective-switcher').click(); + const newEntry = page.getByTestId('sidebar-create-collective'); + await expect(newEntry).toBeVisible({ timeout: 5_000 }); + await newEntry.click(); + + const secondName = `Eva Second ${Date.now()}`; + await page.getByTestId('create-collective-modal-name').fill(secondName); + await page.getByTestId('create-collective-modal-submit').click(); + + // Landed on /lists with the second collective active. + await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 }); + + // Both collectives in the switcher. Use .first() because the switcher + // header also renders the active collective name. + await page.getByTestId('sidebar-collective-switcher').click(); + await expect( + page.getByTestId('desktop-sidebar').getByText(firstName).first() + ).toBeVisible({ timeout: 5_000 }); + await expect( + page.getByTestId('desktop-sidebar').getByText(secondName).first() + ).toBeVisible(); + + // DB-side: Eva has two collectives. + const r = await sql( + 'SELECT count(*)::int AS n FROM public.collectives WHERE created_by = $1', + [USERS.eva.id] + ); + expect(r.rows[0].n).toBe(2); + }); +});