diff --git a/src/components/shared/MapScreenLayout/MapScreenLayout.stories.tsx b/src/components/shared/MapScreenLayout/MapScreenLayout.stories.tsx new file mode 100644 index 00000000..40ee4cc7 --- /dev/null +++ b/src/components/shared/MapScreenLayout/MapScreenLayout.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/tanstack-react'; + +import { MapScreenLayout } from '@/components/shared/MapScreenLayout/MapScreenLayout'; + +const meta: Meta = { + title: 'Components/MapScreenLayout', + component: MapScreenLayout, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const WithTopRight: Story = { + args: { + topRight: ( + + ), + }, +}; + +export const WithBottomButtons: Story = { + args: { + bottomLeft: ( + + ), + bottomRight: ( + + ), + }, +}; + +export const WithSidebar: Story = { + args: { + sidebar: ( +
+

Sidebar

+

Desktop sidebar content

+
+ ), + }, +}; + +export const NonInteractive: Story = { + args: {}, +}; diff --git a/src/components/shared/MapScreenLayout/MapScreenLayout.tsx b/src/components/shared/MapScreenLayout/MapScreenLayout.tsx new file mode 100644 index 00000000..d3bbbd89 --- /dev/null +++ b/src/components/shared/MapScreenLayout/MapScreenLayout.tsx @@ -0,0 +1,69 @@ +import type { CSSProperties, ReactNode } from 'react'; + +import { useIsDesktop } from '@/hooks/useIsDesktop'; + +export interface MapScreenLayoutProps { + className?: string; + style?: CSSProperties; + sidebar?: ReactNode; + topLeft?: ReactNode; + topRight?: ReactNode; + bottomLeft?: ReactNode; + bottomRight?: ReactNode; + children?: ReactNode; +} + +export function MapScreenLayout({ + className, + style, + sidebar, + topLeft, + topRight, + bottomLeft, + bottomRight, + children, +}: MapScreenLayoutProps) { + const isDesktop = useIsDesktop(); + + return ( +
+
+ {children} + + {topLeft && ( +
+ {topLeft} +
+ )} + + {topRight && ( +
+ {topRight} +
+ )} + + {bottomLeft && ( +
+ {bottomLeft} +
+ )} + + {bottomRight && ( +
+ {bottomRight} +
+ )} +
+ + {/* eslint-disable-next-line eqeqeq -- intentional null/undefined check */} + {isDesktop && sidebar != null && ( + + )} +
+ ); +} diff --git a/src/components/shared/MapScreenLayout/index.ts b/src/components/shared/MapScreenLayout/index.ts new file mode 100644 index 00000000..c9809f5f --- /dev/null +++ b/src/components/shared/MapScreenLayout/index.ts @@ -0,0 +1,2 @@ +export { MapScreenLayout } from './MapScreenLayout'; +export type { MapScreenLayoutProps } from './MapScreenLayout'; diff --git a/src/screens/DataScreen.tsx b/src/screens/DataScreen.tsx index 34c09bcd..876fe50e 100644 --- a/src/screens/DataScreen.tsx +++ b/src/screens/DataScreen.tsx @@ -6,6 +6,7 @@ import { Link, useNavigate } from '@tanstack/react-router'; import { useShellSlot } from '@/components/layout/shell-slot'; import { ExportObservationsButton } from '@/components/shared/ExportObservationsButton'; import { FilterSheet } from '@/components/shared/FilterSheet'; +import { MapScreenLayout } from '@/components/shared/MapScreenLayout'; import { MediaPreview } from '@/components/shared/MediaPreview'; import { ObservationCategoryIcon } from '@/components/shared/ObservationCategoryIcon'; import { ObservationFilterBar } from '@/components/shared/ObservationFilterBar'; @@ -217,22 +218,6 @@ export function DataScreen() { ); } - if (viewMode === 'map') { - return ( -
- - navigate({ - to: '/data/observations/$observationId', - params: { observationId }, - }) - } - /> -
- ); - } return ( <>
@@ -283,15 +268,12 @@ export function DataScreen() { ); }, [ - filteredObs, paginatedObservations, showingStart, showingEnd, totalCount, hasMore, loadMore, - viewMode, - navigate, intl, resetFilters, displayNames, @@ -327,52 +309,36 @@ export function DataScreen() { const observations = observationsQuery.data ?? []; - return ( -
- {/* Title */} -

- {intl.formatMessage(messages.title)} -

- - {/* Toolbar */} -
- - {viewMode === 'grid' ? ( - - ) : ( + // Full-screen map view — returns early so the grid layout below only + // handles the grid case. Uses the shared MapScreenLayout (fills the + // AppShell
via h-full). Renders even when observations are empty + // or pending so the map shows its own empty/loading overlay (no grid + // flash on cold load). + if (viewMode === 'map') { + return ( + + obsFilters.setCategories([])} + onSortChange={obsFilters.setSort} + onClear={obsFilters.reset} + /> +
+ } + topRight={ - )} + } + bottomLeft={ +
+ + + obsFilters.setCategories([])} + onSortChange={obsFilters.setSort} + onClear={obsFilters.reset} + /> +
+ } + bottomRight={ + + } + > + + navigate({ + to: '/data/observations/$observationId', + params: { observationId }, + }) + } + height="h-full" + /> + + ); + } + + return ( +
+ {/* Title */} +

+ {intl.formatMessage(messages.title)} +

+ + {/* Toolbar */} +
+ +
{/* Observations content */} diff --git a/src/stores/view-mode-store.ts b/src/stores/view-mode-store.ts index 7041bc7f..ab4239d5 100644 --- a/src/stores/view-mode-store.ts +++ b/src/stores/view-mode-store.ts @@ -11,7 +11,7 @@ interface ViewModeState { export const useViewModeStore = create()( persist( (set) => ({ - viewMode: 'grid', + viewMode: 'map', setViewMode: (viewMode) => set({ viewMode }), }), { diff --git a/tests/e2e/critical-flows.e2e.ts b/tests/e2e/critical-flows.e2e.ts index 682484cb..84dec7c3 100644 --- a/tests/e2e/critical-flows.e2e.ts +++ b/tests/e2e/critical-flows.e2e.ts @@ -126,6 +126,16 @@ test.describe('Critical User Flows', () => { test('user can navigate from home to data observations list', async ({ page, }) => { + // Seed the persisted view-mode store to grid so the Data screen renders + // the grid branch (with

Data

and observation card links) instead + // of the full-screen map on a fresh context with empty localStorage. + await page.addInitScript(() => + localStorage.setItem( + 'view-mode-preference', + JSON.stringify({ state: { viewMode: 'grid' }, version: 0 }), + ), + ); + await seedProjectWithObservations(page, 'Test Project'); // Navigate to /data via the nav link @@ -148,6 +158,16 @@ test.describe('Critical User Flows', () => { test('user can navigate from data to observation detail', async ({ page, }) => { + // Seed the persisted view-mode store to grid so the Data screen renders + // the grid branch (with

Data

and observation card links) instead + // of the full-screen map on a fresh context with empty localStorage. + await page.addInitScript(() => + localStorage.setItem( + 'view-mode-preference', + JSON.stringify({ state: { viewMode: 'grid' }, version: 0 }), + ), + ); + const projectLocalId = await seedProjectWithObservations( page, 'Test Project', diff --git a/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--default.png b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--default.png new file mode 100644 index 00000000..3b6ca53a Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--default.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--non-interactive.png b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--non-interactive.png new file mode 100644 index 00000000..3b6ca53a Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--non-interactive.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-bottom-buttons.png b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-bottom-buttons.png new file mode 100644 index 00000000..cc8e78c3 Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-bottom-buttons.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-sidebar.png b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-sidebar.png new file mode 100644 index 00000000..d1d63a14 Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-sidebar.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-top-right.png b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-top-right.png new file mode 100644 index 00000000..c194d6cb Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/desktop/components-mapscreenlayout--with-top-right.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--default.png b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--default.png new file mode 100644 index 00000000..ddb81c02 Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--default.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--non-interactive.png b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--non-interactive.png new file mode 100644 index 00000000..ddb81c02 Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--non-interactive.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-bottom-buttons.png b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-bottom-buttons.png new file mode 100644 index 00000000..d473bb77 Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-bottom-buttons.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-sidebar.png b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-sidebar.png new file mode 100644 index 00000000..ddb81c02 Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-sidebar.png differ diff --git a/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-top-right.png b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-top-right.png new file mode 100644 index 00000000..f61c11e7 Binary files /dev/null and b/tests/e2e/storybook-screenshots-baseline/mobile/components-mapscreenlayout--with-top-right.png differ diff --git a/tests/unit/components/shared/MapScreenLayout/MapScreenLayout.test.tsx b/tests/unit/components/shared/MapScreenLayout/MapScreenLayout.test.tsx new file mode 100644 index 00000000..01acddb7 --- /dev/null +++ b/tests/unit/components/shared/MapScreenLayout/MapScreenLayout.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@tests/mocks/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MapScreenLayout } from '@/components/shared/MapScreenLayout/MapScreenLayout'; +import { useIsDesktop } from '@/hooks/useIsDesktop'; + +// MapScreenLayout no longer renders a map itself; children supply it. +// Render a stand-in "map" element as children so slot/child assertions work. +const renderLayout = (props: Record = {}) => + render( + +
map
+
, + ); + +vi.mock('@/hooks/useIsDesktop', () => ({ + useIsDesktop: vi.fn(() => false), +})); + +describe('MapScreenLayout', () => { + beforeEach(() => { + vi.mocked(useIsDesktop).mockReturnValue(false); + }); + + it('renders the map container', () => { + renderLayout(); + expect(screen.getByTestId('map-child')).toBeInTheDocument(); + }); + + it('renders topLeft slot content', () => { + renderLayout({ topLeft: top-left-content }); + expect(screen.getByText('top-left-content')).toBeInTheDocument(); + }); + + it('renders topRight slot content', () => { + renderLayout({ topRight: top-right-content }); + expect(screen.getByText('top-right-content')).toBeInTheDocument(); + }); + + it('renders bottomLeft slot content', () => { + renderLayout({ bottomLeft: bottom-left-content }); + expect(screen.getByText('bottom-left-content')).toBeInTheDocument(); + }); + + it('renders bottomRight slot content', () => { + renderLayout({ bottomRight: bottom-right-content }); + expect(screen.getByText('bottom-right-content')).toBeInTheDocument(); + }); + + it('renders children inside map container', () => { + renderLayout(); + expect(screen.getByTestId('map-child')).toBeInTheDocument(); + expect(screen.getByTestId('map-child').parentElement).not.toBeNull(); + }); + + it('does not render sidebar on mobile', () => { + vi.mocked(useIsDesktop).mockReturnValue(false); + renderLayout({ sidebar:
Sidebar
}); + expect(screen.queryByTestId('sidebar-content')).not.toBeInTheDocument(); + }); + + it('renders sidebar on desktop', () => { + vi.mocked(useIsDesktop).mockReturnValue(true); + renderLayout({ sidebar:
Sidebar
}); + expect(screen.getByTestId('sidebar-content')).toBeInTheDocument(); + }); + + it('does not render topLeft when not provided', () => { + renderLayout(); + expect(screen.queryByText('top-left-content')).not.toBeInTheDocument(); + }); + + it('does not render sidebar when not provided even on desktop', () => { + vi.mocked(useIsDesktop).mockReturnValue(true); + renderLayout(); + // aside should not exist when sidebar is undefined + expect(document.querySelector('aside')).toBeNull(); + }); + + it('renders sidebar when null is explicitly provided on desktop', () => { + vi.mocked(useIsDesktop).mockReturnValue(true); + renderLayout({ sidebar: null }); + // null sidebar should not render the aside + expect(document.querySelector('aside')).toBeNull(); + }); +}); diff --git a/tests/unit/screens/DataScreen.test.tsx b/tests/unit/screens/DataScreen.test.tsx index 59a6ecd6..bd74af26 100644 --- a/tests/unit/screens/DataScreen.test.tsx +++ b/tests/unit/screens/DataScreen.test.tsx @@ -560,6 +560,39 @@ describe('DataScreen', () => { ).not.toBeInTheDocument(); expect(screen.getAllByText('forest').length).toBeGreaterThanOrEqual(1); }); + + it('renders map view by default when viewMode is map', () => { + useViewModeStore.setState({ viewMode: 'map' }); + mockObservationsQuery = { data: defaultObservations, isPending: false }; + + render(); + expect(screen.getByTestId('observations-map')).toBeInTheDocument(); + }); + + it('shows grid toggle button in map view and switches to grid on click', async () => { + const { userEvent } = await import('@tests/mocks/test-utils'); + const user = userEvent.setup(); + useViewModeStore.setState({ viewMode: 'map' }); + mockObservationsQuery = { data: defaultObservations, isPending: false }; + + render(); + + // Grid toggle button should be present in map view + expect( + screen.getByRole('button', { name: /switch to grid view/i }), + ).toBeInTheDocument(); + + // Click to switch to grid + await user.click( + screen.getByRole('button', { name: /switch to grid view/i }), + ); + + // Grid view should be shown + expect( + screen.queryByTestId('observations-map'), + ).not.toBeInTheDocument(); + expect(screen.getAllByText('forest').length).toBeGreaterThanOrEqual(1); + }); }); // ---- Observation filter integration ----