diff --git a/public/mobile-app/src/lib/ConnectedHomepage.svelte b/public/mobile-app/src/lib/ConnectedHomepage.svelte index d1e9d5488..0b93e0509 100644 --- a/public/mobile-app/src/lib/ConnectedHomepage.svelte +++ b/public/mobile-app/src/lib/ConnectedHomepage.svelte @@ -5,6 +5,8 @@ import { buildAgenda } from '$lib/agenda'; import AgendaItem from '$lib/components/AgendaItem.svelte'; import Icon from '$lib/components/Icon.svelte'; + import Logout from '$lib/components/modal/Logout.svelte'; + import Modal from '$lib/components/modal/Modal.svelte'; import RequestItem from '$lib/components/RequestItem.svelte'; import type { FollowUp } from '$lib/follow-up'; import { buildFollowUp } from '$lib/follow-up'; @@ -14,6 +16,12 @@ } from '$lib/notifications'; import { userStore } from '$lib/state/User.svelte'; + type ModalInstance = { + open: () => Promise; + }; + + let logoutModal: ModalInstance; + let unreadNotificationsCount: number = $state(0); let initials: string = $state(''); let isMenuDisplayed: boolean = $state(false); @@ -76,19 +84,9 @@ goto('/#/contact'); }; - const logoutModal = () => { + const openLogoutModal = () => { isMenuDisplayed = false; - const modal = document.querySelector('#modal-logout'); - if (modal && typeof window.dsfr === 'function') { - window.dsfr(modal).modal.disclose(); - } - }; - - const logoutModalClose = () => { - const modal = document.querySelector('#modal-logout'); - if (modal && typeof window.dsfr === 'function') { - window.dsfr(modal).modal.conceal(); - } + logoutModal.open(); }; @@ -154,7 +152,7 @@ Nous contacter - - -
  • - -
  • - - - - - - - + title="Suppression de vos données" + closeButton={false} + centered={true} + component={Logout} +/> diff --git a/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts b/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts index 195063eef..1b6c24e1f 100644 --- a/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts +++ b/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts @@ -391,42 +391,4 @@ describe('/ConnectedHomepage.svelte', () => { expect(followUpBlock).toHaveTextContent('Retrouvez et suivez vos démarches ici.'); }); }); - - test('should call userStore.logout', async () => { - // Given - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })); - const spyLogout = vi.spyOn(userStore, 'logout').mockResolvedValue(); - - // When - render(ConnectedHomepage); - const franceConnectLogoutButton = screen.getByRole('button', { - name: 'Me déconnecter', - }); - await franceConnectLogoutButton.click(); - - const confirmButton = screen.getByTestId('logout-submit-button'); - await confirmButton.click(); - - // Then - expect(spyLogout).toHaveBeenCalled(); - }); - - test('should not call userStore.logout if cancel button is clicked', async () => { - // Given - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })); - const spyLogout = vi.spyOn(userStore, 'logout').mockResolvedValue(); - - // When - render(ConnectedHomepage); - const franceConnectLogoutButton = screen.getByRole('button', { - name: 'Me déconnecter', - }); - await franceConnectLogoutButton.click(); - - const confirmButton = screen.getByTestId('logout-cancel-button'); - await confirmButton.click(); - - // Then - expect(spyLogout).not.toHaveBeenCalled(); - }); }); diff --git a/public/mobile-app/src/lib/components/Toggle.svelte b/public/mobile-app/src/lib/components/Toggle.svelte index 40a58c3d1..20fad40e8 100644 --- a/public/mobile-app/src/lib/components/Toggle.svelte +++ b/public/mobile-app/src/lib/components/Toggle.svelte @@ -41,7 +41,7 @@ diff --git a/public/mobile-app/src/lib/components/modal/LogoutFooter.svelte.test.ts b/public/mobile-app/src/lib/components/modal/LogoutFooter.svelte.test.ts new file mode 100644 index 000000000..85d06c0bf --- /dev/null +++ b/public/mobile-app/src/lib/components/modal/LogoutFooter.svelte.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userStore } from '$lib/state/User.svelte'; +import LogoutFooter from './LogoutFooter.svelte'; + +describe('/LogoutFooter.svelte', () => { + test('should call userStore.logout', async () => { + // Given + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })); + const spyLogout = vi.spyOn(userStore, 'logout').mockResolvedValue(); + const onClose = vi.fn(); + + // When + render(LogoutFooter, { props: { onClose } }); + const confirmButton = screen.getByTestId('logout-submit-button'); + await confirmButton.click(); + + // Then + expect(spyLogout).toHaveBeenCalled(); + }); + + test('should not call userStore.logout if cancel button is clicked', async () => { + // Given + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })); + const spyLogout = vi.spyOn(userStore, 'logout').mockResolvedValue(); + const onClose = vi.fn(); + + // When + render(LogoutFooter, { props: { onClose } }); + const cancelButton = screen.getByTestId('logout-cancel-button'); + await cancelButton.click(); + + // Then + expect(spyLogout).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/public/mobile-app/src/lib/components/modal/Modal.svelte b/public/mobile-app/src/lib/components/modal/Modal.svelte new file mode 100644 index 000000000..3ceaf80bf --- /dev/null +++ b/public/mobile-app/src/lib/components/modal/Modal.svelte @@ -0,0 +1,178 @@ + + + +
    +
    +
    +
    +
    + {#if closeButton} + + {/if} +

    {title}

    +
    +
    + +
    +
    +
    +
    +
    + + diff --git a/public/mobile-app/src/lib/components/modal/Modal.svelte.test.ts b/public/mobile-app/src/lib/components/modal/Modal.svelte.test.ts new file mode 100644 index 000000000..08f740ed3 --- /dev/null +++ b/public/mobile-app/src/lib/components/modal/Modal.svelte.test.ts @@ -0,0 +1,158 @@ +import { render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import Modal from './Modal.svelte'; + +const { mockMount, mockUnmount, mockDisclose, mockConceal, mockDsfr } = vi.hoisted( + () => { + const mockDisclose = vi.fn(); + const mockConceal = vi.fn(); + const mockDsfr = vi.fn(() => ({ + modal: { disclose: mockDisclose, conceal: mockConceal }, + })); + const mockMount = vi.fn( + ( + _component: unknown, + _attrs: { target: HTMLElement; props: Record } + ) => ({ destroy: vi.fn() }) + ); + const mockUnmount = vi.fn(); + + return { mockMount, mockUnmount, mockDisclose, mockConceal, mockDsfr }; + } +); + +vi.mock('svelte', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mount: mockMount, + unmount: mockUnmount, + }; +}); + +const FakeComponent = vi.fn(); + +describe('Modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.dsfr = mockDsfr; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('should render modal with correct id and title', () => { + // Given + render(Modal, { + props: { + id: 'test-modal', + title: 'Title', + component: FakeComponent, + }, + }); + + // When + const dialog = screen.getByRole('dialog', { hidden: true }); + + // Then + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('id', 'test-modal'); + expect(dialog).toHaveAttribute('aria-labelledby', 'test-modal-title'); + expect(screen.getByText('Title')).toBeInTheDocument(); + }); + + test('Should open component with props', async () => { + // Given + const customProps = { foo: 'bar' }; + const { component } = render(Modal, { + props: { + id: 'test-modal', + title: 'Title', + component: FakeComponent, + props: customProps, + }, + }); + + // When + await component.open(); + + // Then + expect(mockMount).toHaveBeenCalledOnce(); + const [mountedComponent, mountOptions] = mockMount.mock.calls[0]; + expect(mountedComponent).toBe(FakeComponent); + expect(mountOptions.props).toMatchObject({ + foo: 'bar', + onClose: expect.any(Function), + footerTarget: expect.any(HTMLElement), + }); + }); + + test('open should call dsfr.modal.disclose', async () => { + // Given + const { component } = render(Modal, { + props: { id: 'test-modal', title: 'Title', component: FakeComponent }, + }); + + // When + await component.open(); + + // Then + expect(mockDsfr).toHaveBeenCalled(); + expect(mockDisclose).toHaveBeenCalledOnce(); + }); + + test('onClose should call dsfr.modal.conceal', async () => { + // Given + const { component } = render(Modal, { + props: { id: 'test-modal', title: 'Title', component: FakeComponent }, + }); + await component.open(); + + // When + const mountOptions = mockMount.mock.calls[0][1] as unknown as { + props: { onClose: () => void }; + }; + mountOptions.props.onClose(); + + // Then + expect(mockConceal).toHaveBeenCalledOnce(); + }); + + test('dsfr.modal.conceal whould call onCloseCustom', async () => { + // Given + const { component } = render(Modal, { + props: { id: 'test-modal', title: 'Title', component: FakeComponent }, + }); + await component.open(); + + // When + const mountOptions = mockMount.mock.calls[0][1] as unknown as { + props: { onClose: () => void }; + }; + mountOptions.props.onClose(); + + // Then + expect(mockConceal).toHaveBeenCalledOnce(); + }); + + test('dsfr.conceal listner should be removed when modal is unmounted', async () => { + // Given + const removeEventListenerSpy = vi.spyOn( + HTMLDialogElement.prototype, + 'removeEventListener' + ); + const { unmount: unmountComponent } = render(Modal, { + props: { id: 'test-modal', title: 'Title', component: FakeComponent }, + }); + + // When + unmountComponent(); + + // Then + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'dsfr.conceal', + expect.any(Function) + ); + }); +}); diff --git a/public/mobile-app/src/lib/components/modal/ZonePreferences.svelte b/public/mobile-app/src/lib/components/modal/ZonePreferences.svelte new file mode 100644 index 000000000..e18498238 --- /dev/null +++ b/public/mobile-app/src/lib/components/modal/ZonePreferences.svelte @@ -0,0 +1,65 @@ + + +
    + {#each zoneInfos as zoneInfo} + + {/each} +
    diff --git a/public/mobile-app/src/routes/preferences/zones/page.svelte.test.ts b/public/mobile-app/src/lib/components/modal/ZonePreferences.svelte.test.ts similarity index 72% rename from public/mobile-app/src/routes/preferences/zones/page.svelte.test.ts rename to public/mobile-app/src/lib/components/modal/ZonePreferences.svelte.test.ts index e6c57dd30..10358e318 100644 --- a/public/mobile-app/src/routes/preferences/zones/page.svelte.test.ts +++ b/public/mobile-app/src/lib/components/modal/ZonePreferences.svelte.test.ts @@ -3,16 +3,17 @@ import { describe, expect, test, vi } from 'vitest'; import * as navigationMethods from '$app/navigation'; import { Preferences } from '$lib/state/preferences'; import { userStore } from '$lib/state/User.svelte'; -import { expectBackButtonPresent, mockUserInfo } from '$tests/utils'; -import Page from './+page.svelte'; +import { mockUserInfo } from '$tests/utils'; +import ZonePreferences from './ZonePreferences.svelte'; -describe('/+page.svelte', () => { +describe('/ZonePreferences.svelte', () => { test('user has to be connected', async () => { // Given const spy = vi.spyOn(navigationMethods, 'goto').mockResolvedValue(); + const onClose = vi.fn(); // When - render(Page); + render(ZonePreferences, { props: { onClose } }); // Then await waitFor(() => { @@ -58,9 +59,10 @@ describe('/+page.svelte', () => { zone: 'Corse', }, ]); + const onClose = vi.fn(); // When - render(Page); + render(ZonePreferences, { props: { onClose } }); // Then expect(screen.getByText('Paris (75) 🏠')).toBeInTheDocument(); @@ -72,7 +74,8 @@ describe('/+page.svelte', () => { test('should enable zone when user toggles on', async () => { // Given await userStore.login(mockUserInfo); - render(Page); + const onClose = vi.fn(); + render(ZonePreferences, { props: { onClose } }); // When const toggleInput = screen.getByTestId('Martinique'); @@ -93,7 +96,8 @@ describe('/+page.svelte', () => { test('should disable zone when user toggles off', async () => { // Given await userStore.login(mockUserInfo); - render(Page); + const onClose = vi.fn(); + render(ZonePreferences, { props: { onClose } }); // When const toggleInput = screen.getByTestId('Zone C'); @@ -108,38 +112,4 @@ describe('/+page.svelte', () => { const parsed = JSON.parse(localStorage.getItem('user_identity') || '{}'); expect(parsed?.preferences).toEqual(userStore.connected?.identity.preferences); }); - - test('should import NavWithBackButton component', async () => { - // When - render(Page); - const backButton = screen.getByTestId('back-button'); - - // Then - expect(backButton).toBeInTheDocument(); - expect(screen.getByText('Zones scolaires')).toBeInTheDocument(); - }); - - test('should navigate to previous page when user clicks on Close button', async () => { - // Given - await userStore.login(mockUserInfo); - const backSpy = vi - .spyOn(navigationMethods, 'goto') - .mockImplementation(() => Promise.resolve()); - - // When - render(Page); - const closeButton = screen.getByTestId('close-button'); - await fireEvent.click(closeButton); - - // Then - expect(backSpy).toHaveBeenCalledTimes(1); - }); - - test('should render a Back button', async () => { - // When - render(Page); - - // Then - expectBackButtonPresent(screen); - }); }); diff --git a/public/mobile-app/src/lib/components/modal/ZonePreferencesFooter.svelte b/public/mobile-app/src/lib/components/modal/ZonePreferencesFooter.svelte new file mode 100644 index 000000000..c43f4f492 --- /dev/null +++ b/public/mobile-app/src/lib/components/modal/ZonePreferencesFooter.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/public/mobile-app/src/routes/agenda/+page.svelte b/public/mobile-app/src/routes/agenda/+page.svelte index 369ebd5e4..778b770f8 100644 --- a/public/mobile-app/src/routes/agenda/+page.svelte +++ b/public/mobile-app/src/routes/agenda/+page.svelte @@ -4,10 +4,17 @@ import type { Agenda } from '$lib/agenda'; import { buildAgenda } from '$lib/agenda'; import AgendaItem from '$lib/components/AgendaItem.svelte'; + import Modal from '$lib/components/modal/Modal.svelte'; + import ZonePreferences from '$lib/components/modal/ZonePreferences.svelte'; import Navigation from '$lib/components/Navigation.svelte'; import { userStore } from '$lib/state/User.svelte'; + type ModalInstance = { + open: () => Promise; + }; + let agenda: Agenda | null = $state(null); + let zonePreferencesModal: ModalInstance; onMount(async () => { if (!userStore.connected) { @@ -17,18 +24,28 @@ agenda = await buildAgenda(); console.log($state.snapshot(agenda)); }); + + const openZonePreferencesModal = () => { + zonePreferencesModal.open(); + }; + + const refreshAgenda = () => { + buildAgenda().then((result) => { + agenda = result; + }); + };

    Mon agenda

    @@ -64,6 +81,14 @@
    + +