diff --git a/CLAUDE.md b/CLAUDE.md index 2400ceba..342fd001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,4 +122,10 @@ Uses **Directus** as headless CMS with: - **TypeScript strict mode** ensures type safety - Pre-commit hooks run linting checks via `scripts/check-lint.sh` - Coverage reporting for unit tests -- Automated dependency updates via `npm-check-updates` \ No newline at end of file +- Automated dependency updates via `npm-check-updates` + +## CSS and Styling Conventions + +- **Tailwind CSS Prefix**: Always use the `tw:` prefix for all Tailwind CSS classes (e.g., `tw:flex`, `tw:bg-base-100`) +- **DaisyUI Components**: Use the `tw:` prefix for all DaisyUI component classes (e.g., `tw:btn`, `tw:card`, `tw:modal`) +- This prefix system prevents conflicts with other CSS frameworks and maintains consistent styling across the codebase \ No newline at end of file diff --git a/lib/src/Components/Map/Subcomponents/Controls/LocateControl.spec.tsx b/lib/src/Components/Map/Subcomponents/Controls/LocateControl.spec.tsx new file mode 100644 index 00000000..4ca6c017 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/Controls/LocateControl.spec.tsx @@ -0,0 +1,484 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { render, screen, fireEvent, act } from '@testing-library/react' +import { MapContainer } from 'react-leaflet' +import { MemoryRouter } from 'react-router-dom' +import { toast } from 'react-toastify' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +import { LocateControl } from './LocateControl' + +import type { Item } from '#types/Item' +import type { ItemsApi } from '#types/ItemsApi' +import type { ItemType } from '#types/ItemType' +import type { LayerProps as Layer } from '#types/LayerProps' +import type { MarkerIcon } from '#types/MarkerIcon' +import type { Mock } from 'vitest' + +interface User { + id: string + first_name: string + last_name: string + email: string +} + +// Mock external dependencies +vi.mock('react-toastify', () => ({ + toast: { + loading: vi.fn(() => 'toast-id'), + update: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('react-leaflet', async () => { + const actual = await vi.importActual('react-leaflet') + return { + ...actual, + useMap: vi.fn(() => ({ + closePopup: vi.fn(), + })), + useMapEvents: vi.fn((eventHandlers) => { + ;(global as any).mockMapEventHandlers = eventHandlers + return null + }), + } +}) + +vi.mock('leaflet', () => ({ + control: { + locate: vi.fn(() => ({ + addTo: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + })), + }, +})) + +vi.mock('#components/Auth/useAuth') +vi.mock('#components/Map/hooks/useMyProfile') +vi.mock('#components/Map/hooks/useItems') +vi.mock('#components/Map/hooks/useLayers') + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + } +}) + +const mockUser: User = { + id: 'user-1', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', +} + +const mockApi: ItemsApi = { + getItems: vi.fn().mockResolvedValue([]), + getItem: vi.fn(), + createItem: vi.fn(), + updateItem: vi.fn(), + deleteItem: vi.fn(), + collectionName: 'test-collection', +} + +const mockMarkerIcon: MarkerIcon = { + image: 'test-icon.svg', + size: 32, +} + +const mockItemType: ItemType = { + name: 'user', + show_name_input: true, + show_profile_button: true, + show_start_end: false, + show_start_end_input: false, + show_text: true, + show_text_input: true, + custom_text: '', + profileTemplate: [], + offers_and_needs: false, + icon_as_labels: false, + relations: false, + template: 'simple', + questlog: false, +} + +const mockLayer: Layer = { + id: 'layer-1', + name: 'Users', + menuIcon: 'user', + menuColor: '#ff0000', + menuText: 'Users', + markerIcon: mockMarkerIcon, + markerShape: 'circle', + markerDefaultColor: '#ff0000', + itemType: mockItemType, + userProfileLayer: true, + api: mockApi, +} + +const mockProfile: Item = { + id: 'profile-1', + name: 'John Doe', + position: { + type: 'Point', + coordinates: [10.0, 50.0], + }, + user_created: mockUser, + layer: mockLayer, +} + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +) + +describe('', () => { + let mockUseAuth: Mock + let mockUseMyProfile: Mock + let mockUseUpdateItem: Mock + let mockUseAddItem: Mock + let mockUseLayers: Mock + + beforeEach(async () => { + vi.clearAllMocks() + vi.useFakeTimers() + mockNavigate.mockClear() + ;(global as any).mockMapEventHandlers = {} + + const { useAuth } = await import('#components/Auth/useAuth') + const { useMyProfile } = await import('#components/Map/hooks/useMyProfile') + const { useUpdateItem, useAddItem } = await import('#components/Map/hooks/useItems') + const { useLayers } = await import('#components/Map/hooks/useLayers') + + mockUseAuth = vi.mocked(useAuth) + mockUseMyProfile = vi.mocked(useMyProfile) + mockUseUpdateItem = vi.mocked(useUpdateItem) + mockUseAddItem = vi.mocked(useAddItem) + mockUseLayers = vi.mocked(useLayers) + + mockUseAuth.mockReturnValue({ user: mockUser }) + mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true }) + mockUseUpdateItem.mockReturnValue(vi.fn()) + mockUseAddItem.mockReturnValue(vi.fn()) + mockUseLayers.mockReturnValue([mockLayer]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Component Rendering', () => { + it('renders the locate control button', () => { + render( + + + , + ) + + const button = screen.getByRole('button', { name: /start location tracking/i }) + expect(button).toBeInTheDocument() + }) + + it('displays target icon when not active', () => { + render( + + + , + ) + + const button = screen.getByRole('button', { name: /start location tracking/i }) + expect(button).toBeInTheDocument() + expect(button.querySelector('svg')).toBeInTheDocument() + }) + + it('matches snapshot', () => { + const { container } = render( + + + , + ) + expect(container.firstChild).toMatchSnapshot() + }) + }) + + describe('Modal Display Logic', () => { + it('shows modal for new user without profile when location is found', () => { + mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true }) + + render( + + + , + ) + + const locationEvent = { + latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) }, + } + + act(() => { + ;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent) + }) + + act(() => { + vi.runAllTimers() + }) + + // Check if modal appears after timeout + expect(screen.getByText(/create your profile at your current location/i)).toBeInTheDocument() + }) + + it('shows modal for existing user when location is far from current position', () => { + const profileWithPosition = { + ...mockProfile, + position: { + type: 'Point' as const, + coordinates: [10.0, 50.0], + }, + } + mockUseMyProfile.mockReturnValue({ myProfile: profileWithPosition, isMyProfileLoaded: true }) + + render( + + + , + ) + + const locationEvent = { + latlng: { + lat: 52.5, + lng: 13.4, + distanceTo: vi.fn(() => 200), + }, + } + + act(() => { + ;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent) + }) + + act(() => { + vi.runAllTimers() + }) + + // Check if modal appears after timeout + expect(screen.getByText(/place your profile at your current location/i)).toBeInTheDocument() + }) + }) + + describe('Profile Creation', () => { + it('creates new profile when user has no existing profile', async () => { + const mockCreateItem = vi.fn().mockResolvedValue({ + id: 'new-profile-1', + name: 'John', + position: { type: 'Point', coordinates: [13.4, 52.5] }, + }) + const mockAddItem = vi.fn() + + mockApi.createItem = mockCreateItem + mockUseAddItem.mockReturnValue(mockAddItem) + mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true }) + + render( + + + , + ) + + const locationEvent = { + latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) }, + } + + act(() => { + ;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent) + }) + + act(() => { + vi.runAllTimers() + }) + + // Check if modal appears after timeout + expect(screen.getByText(/create your profile/i)).toBeInTheDocument() + + const yesButton = screen.getByText('Yes') + + await act(async () => { + fireEvent.click(yesButton) + // Allow promises to resolve + await vi.runAllTimersAsync() + }) + + // Verify API calls were made + expect(mockCreateItem).toHaveBeenCalled() + expect(mockAddItem).toHaveBeenCalled() + expect(toast.loading).toHaveBeenCalledWith('Creating profile at location') + }) + + it('updates existing profile position', async () => { + const mockUpdateItem = vi.fn().mockResolvedValue({ + id: 'profile-1', + position: { type: 'Point', coordinates: [13.4, 52.5] }, + }) + const mockUpdateItemHook = vi.fn() + + // Create a profile with a current position far from the new location + const profileWithCurrentPosition = { + ...mockProfile, + position: { + type: 'Point' as const, + coordinates: [10.0, 50.0], // lng, lat format - far from 13.4, 52.5 + }, + } + + if (profileWithCurrentPosition.layer?.api) { + profileWithCurrentPosition.layer.api.updateItem = mockUpdateItem + } + mockUseUpdateItem.mockReturnValue(mockUpdateItemHook) + mockUseMyProfile.mockReturnValue({ + myProfile: profileWithCurrentPosition, + isMyProfileLoaded: true, + }) + + render( + + + , + ) + + // Mock distanceTo to return a distance > 100m + const mockDistanceTo = vi.fn(() => 200) + const locationEvent = { + latlng: { lat: 52.5, lng: 13.4, distanceTo: mockDistanceTo }, + } + act(() => { + ;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent) + }) + + // Verify distanceTo was called with swapped coordinates [lat, lng] + // Verify distanceTo was called with swapped coordinates [lat, lng] + expect(mockDistanceTo).toHaveBeenCalledWith([50.0, 10.0]) + + act(() => { + vi.runAllTimers() + }) + + // Check if modal appears after timeout + expect(screen.getByText(/place your profile/i)).toBeInTheDocument() + + // Find the Yes button by text content instead of role + const yesButton = screen.getByText('Yes') + + await act(async () => { + fireEvent.click(yesButton) + // Allow promises to resolve + await vi.runAllTimersAsync() + }) + + // Verify API calls were made + expect(mockUpdateItem).toHaveBeenCalled() + expect(mockUpdateItemHook).toHaveBeenCalled() + expect(toast.loading).toHaveBeenCalledWith('Updating position') + }) + }) + + describe('Navigation', () => { + it('navigates to profile after successful creation', async () => { + const mockCreateItem = vi.fn().mockResolvedValue({ + id: 'new-profile-1', + name: 'John', + position: { type: 'Point', coordinates: [13.4, 52.5] }, + }) + + mockApi.createItem = mockCreateItem + mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true }) + + render( + + + , + ) + + const locationEvent = { + latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) }, + } + + act(() => { + ;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent) + }) + + act(() => { + vi.runAllTimers() + }) + + // Check if modal appears after timeout + expect(screen.getByText(/create your profile/i)).toBeInTheDocument() + + const yesButton = screen.getByText('Yes') + + await act(async () => { + fireEvent.click(yesButton) + // Allow promises to resolve + await vi.runAllTimersAsync() + }) + + // Verify navigation was called + expect(mockNavigate).toHaveBeenCalledWith('/new-profile-1') + }) + }) + + describe('Error Handling', () => { + it('handles API errors gracefully', async () => { + const mockCreateItem = vi.fn().mockRejectedValue(new Error('Network error')) + mockApi.createItem = mockCreateItem + mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true }) + + render( + + + , + ) + + const locationEvent = { + latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) }, + } + + act(() => { + ;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent) + }) + + act(() => { + vi.runAllTimers() + }) + + // Check if modal appears after timeout + expect(screen.getByText(/create your profile/i)).toBeInTheDocument() + + const yesButton = screen.getByText('Yes') + + await act(async () => { + fireEvent.click(yesButton) + // Allow promises to resolve + await vi.runAllTimersAsync() + }) + + // Verify error toast was shown + expect(toast.update).toHaveBeenCalledWith('toast-id', { + render: 'Network error', + type: 'error', + isLoading: false, + autoClose: 5000, + closeButton: true, + }) + }) + }) +}) diff --git a/lib/src/Components/Map/Subcomponents/Controls/LocateControl.tsx b/lib/src/Components/Map/Subcomponents/Controls/LocateControl.tsx index ecc3a061..885caa5e 100644 --- a/lib/src/Components/Map/Subcomponents/Controls/LocateControl.tsx +++ b/lib/src/Components/Map/Subcomponents/Controls/LocateControl.tsx @@ -1,72 +1,297 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/prefer-ts-expect-error */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ import { control } from 'leaflet' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import SVG from 'react-inlinesvg' import { useMap, useMapEvents } from 'react-leaflet' +import { useNavigate } from 'react-router-dom' +import { toast } from 'react-toastify' import TargetSVG from '#assets/target.svg' +import { useAuth } from '#components/Auth/useAuth' +import { useAddItem, useUpdateItem } from '#components/Map/hooks/useItems' +import { useLayers } from '#components/Map/hooks/useLayers' +import { useMyProfile } from '#components/Map/hooks/useMyProfile' +import DialogModal from '#components/Templates/DialogModal' + +import type { Item } from '#types/Item' +import type { LatLng } from 'leaflet' // eslint-disable-next-line import/no-unassigned-import import 'leaflet.locatecontrol' -// Converts leaflet.locatecontrol to a React Component -export const LocateControl = () => { - const map = useMap() +// Type definitions for leaflet.locatecontrol +declare module 'leaflet' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace control { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function locate(options?: object): any + } +} - // prevent react18 from calling useEffect twice +/** + * React wrapper for leaflet.locatecontrol that provides user geolocation functionality + * @category Map Controls + */ +export const LocateControl = (): JSX.Element => { + const map = useMap() + const myProfile = useMyProfile() + const updateItem = useUpdateItem() + const addItem = useAddItem() + const layers = useLayers() + const { user } = useAuth() + const navigate = useNavigate() + + // Prevent React 18 StrictMode from calling useEffect twice const init = useRef(false) + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const [lc, setLc] = useState(null) const [active, setActive] = useState(false) const [loading, setLoading] = useState(false) + const [showLocationModal, setShowLocationModal] = useState(false) + const [foundLocation, setFoundLocation] = useState(null) + const [hasUpdatedPosition, setHasUpdatedPosition] = useState(false) + const [hasDeclinedModal, setHasDeclinedModal] = useState(false) + const timeoutRef = useRef(null) + + const currentPosition = myProfile.myProfile?.position?.coordinates ?? null + + // Determine if modal should be shown based on distance and conditions + const shouldShowModal = useCallback( + (targetLocation: LatLng | null, hasUpdated: boolean): boolean => { + if (!targetLocation || hasUpdated || hasDeclinedModal || !user) return false + + // Show modal if user has no profile (new user) + if (!myProfile.myProfile) return true + + // Show modal if user has no current position + if (!currentPosition) return true + + const distance = targetLocation.distanceTo([currentPosition[1], currentPosition[0]]) + return distance >= 100 + }, + [myProfile.myProfile, currentPosition, hasDeclinedModal, user], + ) useEffect(() => { if (!init.current) { - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call setLc(control.locate().addTo(map)) init.current = true } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Check if user logged in while location is active and found + useEffect(() => { + if ( + active && + foundLocation && + !showLocationModal && + shouldShowModal(foundLocation, hasUpdatedPosition) + ) { + timeoutRef.current = setTimeout(() => { + setShowLocationModal(true) + }, 1000) + } + }, [active, foundLocation, showLocationModal, hasUpdatedPosition, shouldShowModal]) + useMapEvents({ - locationfound: () => { + locationfound: (e) => { setLoading(false) setActive(true) + setFoundLocation(e.latlng) + }, + locationerror: () => { + setLoading(false) + setActive(false) }, }) + const handleLocateClick = (): void => { + if (!lc) return + + if (active) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + lc.stop() + setActive(false) + setHasDeclinedModal(false) // Reset declined state when turning off location + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + lc.start() + setLoading(true) + setHasDeclinedModal(false) // Reset declined state when turning on location + } + } + + const itemUpdatePosition = useCallback(async () => { + if (!foundLocation || !user) return + + const toastId = toast.loading( + myProfile.myProfile ? 'Updating position' : 'Creating profile at location', + ) + + try { + let result: Item + + if (myProfile.myProfile) { + // Update existing profile + const updatedProfile = { + id: myProfile.myProfile.id, + position: { type: 'Point', coordinates: [foundLocation.lng, foundLocation.lat] }, + } + if (!myProfile.myProfile.layer?.api?.updateItem) { + throw new Error('Update API not available') + } + result = await myProfile.myProfile.layer.api.updateItem(updatedProfile as Item) + // Use server response for local state update + updateItem({ ...result, layer: myProfile.myProfile.layer }) + toast.update(toastId, { + render: 'Position updated', + type: 'success', + isLoading: false, + autoClose: 5000, + closeButton: true, + }) + } else { + // Create new profile + const userLayer = layers.find((l) => l.userProfileLayer === true) + if (!userLayer?.api?.createItem) { + throw new Error('User profile layer or create API not available') + } + + const newProfile = { + id: crypto.randomUUID(), + name: user.first_name ?? 'User', + position: { type: 'Point', coordinates: [foundLocation.lng, foundLocation.lat] }, + } + + result = await userLayer.api.createItem(newProfile as Item) + // Use server response for local state update + addItem({ + ...result, + user_created: user, + layer: userLayer, + public_edit: false, + }) + toast.update(toastId, { + render: 'Profile created at location', + type: 'success', + isLoading: false, + autoClose: 5000, + closeButton: true, + }) + } + + // Navigate to the profile to show the popup + navigate(`/${result.id}`) + + // Clean up and reset state + setFoundLocation(null) + setActive(false) + setHasUpdatedPosition(true) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (lc) lc.stop() + // Reset flag after a delay to allow future updates + setTimeout(() => setHasUpdatedPosition(false), 5000) + } catch (error: unknown) { + if (error instanceof Error) { + toast.update(toastId, { + render: error.message, + type: 'error', + isLoading: false, + autoClose: 5000, + closeButton: true, + }) + } else if (typeof error === 'string') { + toast.update(toastId, { + render: error, + type: 'error', + isLoading: false, + autoClose: 5000, + closeButton: true, + }) + } else { + throw error + } + } + }, [myProfile.myProfile, foundLocation, updateItem, addItem, layers, user, lc, navigate]) + return ( <> -
+
{ - if (active) { - lc.stop() - setActive(false) - } else { - lc.start() - setLoading(true) + className='tw:card-body tw:card tw:p-2 tw:h-10 tw:w-10' + onClick={handleLocateClick} + role='button' + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleLocateClick() } }} + aria-label={active ? 'Stop location tracking' : 'Start location tracking'} > {loading ? ( - + ) : ( )}
+ setShowLocationModal(false)} + showCloseButton={true} + closeOnClickOutside={true} + className='tw:bottom-1/3 tw:mx-4 tw:sm:mx-auto' + > +
+

+ {myProfile.myProfile + ? 'Do you like to place your profile at your current location?' + : 'Do you like to create your profile at your current location?'} +

+
+ + +
+
+
) } diff --git a/lib/src/Components/Map/Subcomponents/Controls/__snapshots__/LocateControl.spec.tsx.snap b/lib/src/Components/Map/Subcomponents/Controls/__snapshots__/LocateControl.spec.tsx.snap new file mode 100644 index 00000000..6de35afb --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/Controls/__snapshots__/LocateControl.spec.tsx.snap @@ -0,0 +1,137 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > Component Rendering > matches snapshot 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+ + + +
+
+
+`; diff --git a/lib/src/Components/Templates/DialogModal.tsx b/lib/src/Components/Templates/DialogModal.tsx index f2a72ec9..a0e87c26 100644 --- a/lib/src/Components/Templates/DialogModal.tsx +++ b/lib/src/Components/Templates/DialogModal.tsx @@ -33,31 +33,31 @@ const DialogModal = ({ if (isOpened) { ref.current?.showModal() ref.current?.classList.remove('tw:hidden') - document.body.classList.add('modal-open') // prevent bg scroll + document.body.style.overflow = 'hidden' } else { ref.current?.close() ref.current?.classList.add('tw:hidden') - document.body.classList.remove('modal-open') + document.body.style.overflow = '' } }, [isOpened]) if (isOpened) { return ( ref.current && !isClickInsideRectangle(e, ref.current) && closeOnClickOutside && onClose() } > -
+

{title}

{children} {showCloseButton && (