mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-04-06 01:25:33 +00:00
Compare commits
3 Commits
94fa6321ba
...
db423b26f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db423b26f3 | ||
|
|
f7f758bb00 | ||
|
|
649efe551d |
@ -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`
|
||||
- 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
|
||||
@ -2,6 +2,7 @@ import { UtopiaMap, Layer } from "utopia-ui"
|
||||
import { events, places } from "./sample-data"
|
||||
|
||||
const itemTypeEvent = {
|
||||
id: "a6dbf1a7-adf2-4ff5-8e20-d3aad66635fb",
|
||||
name: "event",
|
||||
show_name_input: false,
|
||||
show_profile_button: false,
|
||||
@ -39,6 +40,7 @@ function App() {
|
||||
return (
|
||||
<UtopiaMap center={[50.6, 15.5]} zoom={5} height='100dvh' width="100dvw">
|
||||
<Layer
|
||||
id="8b6892ea-4ca3-4b86-8060-b0371a8dd375"
|
||||
name='events'
|
||||
markerIcon={
|
||||
{image: "calendar.svg",
|
||||
@ -54,6 +56,7 @@ function App() {
|
||||
itemType={itemTypeEvent}
|
||||
/>
|
||||
<Layer
|
||||
id="eea49637-1232-42f9-aec9-77b3187d5d7c"
|
||||
name='places'
|
||||
markerIcon={
|
||||
{image: "point.svg"}
|
||||
|
||||
@ -5,12 +5,14 @@ function App() {
|
||||
return (
|
||||
<UtopiaMap center={[50.6, 15.5]} zoom={5} height='100dvh' width="100dvw">
|
||||
<Layer
|
||||
id="eea49637-1232-42f9-aec9-77b3187d5d7c"
|
||||
name='events'
|
||||
markerIcon='calendar'
|
||||
markerShape='square'
|
||||
markerDefaultColor='#700'
|
||||
data={events} />
|
||||
<Layer
|
||||
id="9b880bc6-2ad0-439a-b3b6-e7907d1d824a"
|
||||
name='places'
|
||||
markerIcon='point'
|
||||
markerShape='circle'
|
||||
|
||||
@ -17,6 +17,7 @@ export type { Popup } from 'leaflet'
|
||||
* @category Map
|
||||
*/
|
||||
export const Layer = ({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
name = 'places',
|
||||
@ -46,6 +47,7 @@ export const Layer = ({
|
||||
useEffect(() => {
|
||||
data &&
|
||||
setItemsData({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
name,
|
||||
@ -68,6 +70,7 @@ export const Layer = ({
|
||||
})
|
||||
api &&
|
||||
setItemsApi({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
name,
|
||||
|
||||
@ -0,0 +1,547 @@
|
||||
/* 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<Item> = {
|
||||
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 }) => (
|
||||
<MemoryRouter>
|
||||
<MapContainer center={[51.505, -0.09]} zoom={13}>
|
||||
{children}
|
||||
</MapContainer>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
describe('<LocateControl />', () => {
|
||||
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(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /start location tracking/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays target icon when not active', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /start location tracking/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
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(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('does not show modal when distance is less than 100m', () => {
|
||||
const profileWithPosition = {
|
||||
...mockProfile,
|
||||
position: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [10.0, 50.0],
|
||||
},
|
||||
}
|
||||
mockUseMyProfile.mockReturnValue({ myProfile: profileWithPosition, isMyProfileLoaded: true })
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Mock distanceTo to return a distance < 100m
|
||||
const locationEvent = {
|
||||
latlng: {
|
||||
lat: 50.001, // Very close to current position
|
||||
lng: 10.001,
|
||||
distanceTo: vi.fn(() => 50), // Distance less than 100m
|
||||
},
|
||||
}
|
||||
|
||||
act(() => {
|
||||
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Modal should not appear
|
||||
expect(
|
||||
screen.queryByText(/place your profile at your current location/i),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show modal when location error occurs', () => {
|
||||
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Simulate location error
|
||||
act(() => {
|
||||
;(global as any).mockMapEventHandlers?.locationerror?.()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Modal should not appear
|
||||
expect(
|
||||
screen.queryByText(/create your profile at your current location/i),
|
||||
).not.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(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TestWrapper>
|
||||
<LocateControl />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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<any>(null)
|
||||
const [active, setActive] = useState<boolean>(false)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [showLocationModal, setShowLocationModal] = useState<boolean>(false)
|
||||
const [foundLocation, setFoundLocation] = useState<LatLng | null>(null)
|
||||
const [hasUpdatedPosition, setHasUpdatedPosition] = useState<boolean>(false)
|
||||
const [hasDeclinedModal, setHasDeclinedModal] = useState<boolean>(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<>
|
||||
<div className='tw:card tw:flex-none tw:h-12 tw:w-12 tw:bg-base-100 tw:shadow-xl tw:items-center tw:justify-center tw:hover:bg-slate-300 tw:hover:cursor-pointer tw:transition-all tw:duration-300 tw:ml-2'>
|
||||
<div className='tw:card tw:flex-none tw:h-12 tw:w-12 tw:bg-base-100 tw:shadow-xl tw:items-center tw:justify-center tw:hover:bg-slate-300 tw:hover:cursor-pointer tw:transition-all tw:duration-300 tw:ml-2'>
|
||||
<div
|
||||
className='tw:card-body tw:card tw:p-2 tw:h-10 tw:w-10 '
|
||||
onClick={() => {
|
||||
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 ? (
|
||||
<span className='tw:loading tw:loading-spinner tw:loading-md tw:mt-1'></span>
|
||||
<span className='tw:loading tw:loading-spinner tw:loading-md tw:mt-1' />
|
||||
) : (
|
||||
<SVG
|
||||
src={TargetSVG}
|
||||
className='tw:mt-1 tw:p-[1px]'
|
||||
style={{ fill: `${active ? '#fc8702' : 'currentColor'}` }}
|
||||
style={{ fill: active ? '#fc8702' : 'currentColor' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogModal
|
||||
title='Location found'
|
||||
isOpened={showLocationModal}
|
||||
onClose={() => setShowLocationModal(false)}
|
||||
showCloseButton={true}
|
||||
closeOnClickOutside={true}
|
||||
className='tw:bottom-1/3 tw:mx-4 tw:sm:mx-auto'
|
||||
>
|
||||
<div className='tw:text-center'>
|
||||
<p className='tw:mb-4'>
|
||||
{myProfile.myProfile
|
||||
? 'Do you like to place your profile at your current location?'
|
||||
: 'Do you like to create your profile at your current location?'}
|
||||
</p>
|
||||
<div className='tw:flex tw:justify-between'>
|
||||
<label
|
||||
className='tw:btn tw:mt-4 tw:btn-primary'
|
||||
onClick={() => {
|
||||
void itemUpdatePosition().then(() => setShowLocationModal(false))
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</label>
|
||||
<label
|
||||
className='tw:btn tw:mt-4'
|
||||
onClick={() => {
|
||||
setShowLocationModal(false)
|
||||
setHasDeclinedModal(true)
|
||||
}}
|
||||
>
|
||||
No
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</DialogModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<LocateControl /> > Component Rendering > matches snapshot 1`] = `
|
||||
<div
|
||||
class="leaflet-container leaflet-touch leaflet-grab leaflet-touch-drag leaflet-touch-zoom"
|
||||
style="position: relative;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="leaflet-pane leaflet-map-pane"
|
||||
style="left: 0px; top: 0px;"
|
||||
>
|
||||
<div
|
||||
class="leaflet-pane leaflet-tile-pane"
|
||||
/>
|
||||
<div
|
||||
class="leaflet-pane leaflet-overlay-pane"
|
||||
/>
|
||||
<div
|
||||
class="leaflet-pane leaflet-shadow-pane"
|
||||
/>
|
||||
<div
|
||||
class="leaflet-pane leaflet-marker-pane"
|
||||
/>
|
||||
<div
|
||||
class="leaflet-pane leaflet-tooltip-pane"
|
||||
/>
|
||||
<div
|
||||
class="leaflet-pane leaflet-popup-pane"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="leaflet-control-container"
|
||||
>
|
||||
<div
|
||||
class="leaflet-top leaflet-left"
|
||||
>
|
||||
<div
|
||||
class="leaflet-control-zoom leaflet-bar leaflet-control"
|
||||
>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
aria-label="Zoom in"
|
||||
class="leaflet-control-zoom-in"
|
||||
href="#"
|
||||
role="button"
|
||||
title="Zoom in"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
aria-label="Zoom out"
|
||||
class="leaflet-control-zoom-out"
|
||||
href="#"
|
||||
role="button"
|
||||
title="Zoom out"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
−
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="leaflet-top leaflet-right"
|
||||
/>
|
||||
<div
|
||||
class="leaflet-bottom leaflet-left"
|
||||
/>
|
||||
<div
|
||||
class="leaflet-bottom leaflet-right"
|
||||
>
|
||||
<div
|
||||
class="leaflet-control-attribution leaflet-control"
|
||||
>
|
||||
<a
|
||||
href="https://leafletjs.com"
|
||||
title="A JavaScript library for interactive maps"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="leaflet-attribution-flag"
|
||||
height="8"
|
||||
viewBox="0 0 12 8"
|
||||
width="12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 0h12v4H0z"
|
||||
fill="#4C7BE1"
|
||||
/>
|
||||
<path
|
||||
d="M0 4h12v3H0z"
|
||||
fill="#FFD500"
|
||||
/>
|
||||
<path
|
||||
d="M0 7h12v1H0z"
|
||||
fill="#E0BC00"
|
||||
/>
|
||||
</svg>
|
||||
Leaflet
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tw:card tw:flex-none tw:h-12 tw:w-12 tw:bg-base-100 tw:shadow-xl tw:items-center tw:justify-center tw:hover:bg-slate-300 tw:hover:cursor-pointer tw:transition-all tw:duration-300 tw:ml-2"
|
||||
>
|
||||
<div
|
||||
aria-label="Start location tracking"
|
||||
class="tw:card-body tw:card tw:p-2 tw:h-10 tw:w-10"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
class="tw:mt-1 tw:p-[1px]"
|
||||
fill="currentColor"
|
||||
style="fill: currentColor;"
|
||||
version="1.1"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M30 14.75h-2.824c-0.608-5.219-4.707-9.318-9.874-9.921l-0.053-0.005v-2.824c0-0.69-0.56-1.25-1.25-1.25s-1.25 0.56-1.25 1.25v0 2.824c-5.219 0.608-9.318 4.707-9.921 9.874l-0.005 0.053h-2.824c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25v0h2.824c0.608 5.219 4.707 9.318 9.874 9.921l0.053 0.005v2.824c0 0.69 0.56 1.25 1.25 1.25s1.25-0.56 1.25-1.25v0-2.824c5.219-0.608 9.318-4.707 9.921-9.874l0.005-0.053h2.824c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25v0zM17.25 24.624v-2.624c0-0.69-0.56-1.25-1.25-1.25s-1.25 0.56-1.25 1.25v0 2.624c-3.821-0.57-6.803-3.553-7.368-7.326l-0.006-0.048h2.624c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25v0h-2.624c0.57-3.821 3.553-6.804 7.326-7.368l0.048-0.006v2.624c0 0.69 0.56 1.25 1.25 1.25s1.25-0.56 1.25-1.25v0-2.624c3.821 0.57 6.803 3.553 7.368 7.326l0.006 0.048h-2.624c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25v0h2.624c-0.571 3.821-3.553 6.803-7.326 7.368l-0.048 0.006z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -1,11 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/prefer-optional-chain */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
/* eslint-disable no-catch-all/no-catch-all */
|
||||
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { Popup as LeafletPopup, useMap } from 'react-leaflet'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
@ -50,95 +47,170 @@ export function ItemFormPopup(props: Props) {
|
||||
|
||||
const { user } = useAuth()
|
||||
|
||||
const handleSubmit = async (evt: any) => {
|
||||
if (!popupForm) {
|
||||
throw new Error('Popup form is not defined')
|
||||
}
|
||||
const formItem: Item = {} as Item
|
||||
Array.from(evt.target).forEach((input: HTMLInputElement) => {
|
||||
if (input.name) {
|
||||
formItem[input.name] = input.value
|
||||
// Extract form data into Item object
|
||||
const parseFormData = useCallback(
|
||||
(evt: React.FormEvent<HTMLFormElement>): Item => {
|
||||
if (!popupForm) {
|
||||
throw new Error('Popup form is not defined')
|
||||
}
|
||||
})
|
||||
formItem.position = {
|
||||
type: 'Point',
|
||||
coordinates: [popupForm.position.lng, popupForm.position.lat],
|
||||
}
|
||||
|
||||
const formItem: Item = {} as Item
|
||||
const formData = new FormData(evt.currentTarget)
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key && typeof value === 'string') {
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
;(formItem as unknown as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
formItem.position = {
|
||||
type: 'Point',
|
||||
coordinates: [popupForm.position.lng, popupForm.position.lat],
|
||||
}
|
||||
|
||||
return formItem
|
||||
},
|
||||
[popupForm],
|
||||
)
|
||||
|
||||
// Process hashtags in text and create new tags if needed
|
||||
const processHashtags = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) return
|
||||
|
||||
text
|
||||
.toLocaleLowerCase()
|
||||
.match(hashTagRegex)
|
||||
?.forEach((tag) => {
|
||||
const tagName = tag.slice(1).toLocaleLowerCase()
|
||||
if (!tags.find((t) => t.name.toLocaleLowerCase() === tagName)) {
|
||||
addTag({ id: crypto.randomUUID(), name: tag.slice(1), color: randomColor() })
|
||||
}
|
||||
})
|
||||
},
|
||||
[tags, addTag],
|
||||
)
|
||||
|
||||
// Handle API operations with consistent error handling and return response data
|
||||
const handleApiOperation = useCallback(
|
||||
async (
|
||||
operation: () => Promise<Item>,
|
||||
successMessage: string,
|
||||
): Promise<{ success: boolean; data?: Item }> => {
|
||||
try {
|
||||
const data = await operation()
|
||||
toast.success(successMessage)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
return { success: false }
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Update existing item
|
||||
const handleUpdateItem = useCallback(
|
||||
async (formItem: Item) => {
|
||||
if (!popupForm?.item) return false
|
||||
|
||||
const result = await handleApiOperation(
|
||||
() =>
|
||||
popupForm.layer.api?.updateItem!({ ...formItem, id: popupForm.item!.id }) ??
|
||||
Promise.resolve({} as Item),
|
||||
'Item updated',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Ensure the item has the layer object attached
|
||||
const itemWithLayer = { ...result.data, layer: popupForm.layer }
|
||||
updateItem(itemWithLayer)
|
||||
}
|
||||
|
||||
return result.success
|
||||
},
|
||||
[popupForm, handleApiOperation, updateItem],
|
||||
)
|
||||
|
||||
// Create new item or update existing user profile
|
||||
const handleCreateItem = useCallback(
|
||||
async (formItem: Item) => {
|
||||
if (!popupForm) return false
|
||||
|
||||
const existingUserItem = items.find(
|
||||
(i) => i.user_created?.id === user?.id && i.layer === popupForm.layer,
|
||||
)
|
||||
|
||||
const itemName = formItem.name || user?.first_name
|
||||
if (!itemName) {
|
||||
toast.error('Name must be defined')
|
||||
return false
|
||||
}
|
||||
|
||||
const isUserProfileUpdate = popupForm.layer.userProfileLayer && existingUserItem
|
||||
|
||||
const operation = isUserProfileUpdate
|
||||
? () =>
|
||||
popupForm.layer.api?.updateItem!({ ...formItem, id: existingUserItem.id }) ??
|
||||
Promise.resolve({} as Item)
|
||||
: () =>
|
||||
popupForm.layer.api?.createItem!({ ...formItem, name: itemName }) ??
|
||||
Promise.resolve({} as Item)
|
||||
|
||||
const result = await handleApiOperation(
|
||||
operation,
|
||||
isUserProfileUpdate ? 'Profile updated' : 'New item created',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Ensure the item has the layer object attached
|
||||
const itemWithLayer = { ...result.data, layer: popupForm.layer }
|
||||
|
||||
if (isUserProfileUpdate) {
|
||||
updateItem(itemWithLayer)
|
||||
} else {
|
||||
addItem(itemWithLayer)
|
||||
}
|
||||
resetFilterTags()
|
||||
}
|
||||
|
||||
return result.success
|
||||
},
|
||||
[popupForm, items, user, handleApiOperation, updateItem, addItem, resetFilterTags],
|
||||
)
|
||||
|
||||
const handleSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault()
|
||||
|
||||
const name = formItem.name ? formItem.name : user?.first_name
|
||||
if (!name) {
|
||||
toast.error('Name is must be defined')
|
||||
return
|
||||
if (!popupForm) {
|
||||
throw new Error('Popup form is not defined')
|
||||
}
|
||||
|
||||
setSpinner(true)
|
||||
|
||||
formItem.text &&
|
||||
formItem.text
|
||||
.toLocaleLowerCase()
|
||||
.match(hashTagRegex)
|
||||
?.map((tag) => {
|
||||
if (!tags.find((t) => t.name.toLocaleLowerCase() === tag.slice(1).toLocaleLowerCase())) {
|
||||
addTag({ id: crypto.randomUUID(), name: tag.slice(1), color: randomColor() })
|
||||
}
|
||||
return null
|
||||
})
|
||||
try {
|
||||
const formItem = parseFormData(evt)
|
||||
|
||||
if (popupForm.item) {
|
||||
let success = false
|
||||
try {
|
||||
await popupForm.layer.api?.updateItem!({ ...formItem, id: popupForm.item.id })
|
||||
success = true
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (error) {
|
||||
toast.error(error.toString())
|
||||
// Process hashtags if text exists
|
||||
if (formItem.text) {
|
||||
processHashtags(formItem.text)
|
||||
}
|
||||
if (success) {
|
||||
updateItem({ ...popupForm.item, ...formItem })
|
||||
toast.success('Item updated')
|
||||
}
|
||||
setSpinner(false)
|
||||
map.closePopup()
|
||||
} else {
|
||||
const item = items.find((i) => i.user_created?.id === user?.id && i.layer === popupForm.layer)
|
||||
|
||||
const uuid = crypto.randomUUID()
|
||||
let success = false
|
||||
try {
|
||||
popupForm.layer.userProfileLayer &&
|
||||
item &&
|
||||
(await popupForm.layer.api?.updateItem!({ ...formItem, id: item.id }))
|
||||
;(!popupForm.layer.userProfileLayer || !item) &&
|
||||
(await popupForm.layer.api?.createItem!({
|
||||
...formItem,
|
||||
name,
|
||||
id: uuid,
|
||||
}))
|
||||
success = true
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (error) {
|
||||
toast.error(error.toString())
|
||||
let success: boolean
|
||||
if (popupForm.item) {
|
||||
success = await handleUpdateItem(formItem)
|
||||
} else {
|
||||
success = await handleCreateItem(formItem)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
if (popupForm.layer.userProfileLayer && item) updateItem({ ...item, ...formItem })
|
||||
if (!popupForm.layer.userProfileLayer || !item) {
|
||||
addItem({
|
||||
...formItem,
|
||||
name: (formItem.name ? formItem.name : user?.first_name) ?? '',
|
||||
user_created: user ?? undefined,
|
||||
id: uuid,
|
||||
layer: popupForm.layer,
|
||||
public_edit: !user,
|
||||
})
|
||||
}
|
||||
toast.success('New item created')
|
||||
resetFilterTags()
|
||||
map.closePopup()
|
||||
setPopupForm(null)
|
||||
}
|
||||
} finally {
|
||||
setSpinner(false)
|
||||
map.closePopup()
|
||||
}
|
||||
setPopupForm(null)
|
||||
}
|
||||
|
||||
const resetPopup = () => {
|
||||
|
||||
@ -3,14 +3,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
/* eslint-disable @typescript-eslint/await-thenable */
|
||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */
|
||||
/* eslint-disable no-catch-all/no-catch-all */
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { useUpdateItem } from './useItems'
|
||||
import { useLayers } from './useLayers'
|
||||
import { useHasUserPermission } from './usePermissions'
|
||||
|
||||
import type { Item } from '#types/Item'
|
||||
@ -44,6 +46,38 @@ function useSelectPositionManager(): {
|
||||
const [mapClicked, setMapClicked] = useState<PolygonClickedProps>()
|
||||
const updateItem = useUpdateItem()
|
||||
const hasUserPermission = useHasUserPermission()
|
||||
const layers = useLayers()
|
||||
|
||||
// Handle API operations with consistent error handling and return response data
|
||||
const handleApiOperation = useCallback(
|
||||
async (
|
||||
operation: () => Promise<Item>,
|
||||
toastId: string | number,
|
||||
successMessage: string,
|
||||
): Promise<{ success: boolean; data?: Item }> => {
|
||||
try {
|
||||
const data = await operation()
|
||||
toast.update(toastId, {
|
||||
render: successMessage,
|
||||
type: 'success',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
})
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
toast.update(toastId, {
|
||||
render: errorMessage,
|
||||
type: 'error',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
closeButton: true,
|
||||
})
|
||||
return { success: false }
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -91,32 +125,25 @@ function useSelectPositionManager(): {
|
||||
markerClicked?.layer?.api?.collectionName &&
|
||||
hasUserPermission(markerClicked.layer.api.collectionName, 'update', markerClicked)
|
||||
) {
|
||||
let success = false
|
||||
try {
|
||||
await updatedItem.layer?.api?.updateItem!({
|
||||
id: updatedItem.id,
|
||||
parent: updatedItem.parent,
|
||||
position: null,
|
||||
})
|
||||
success = true
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.update(toastId, { render: error.message, type: 'error', autoClose: 5000 })
|
||||
} else if (typeof error === 'string') {
|
||||
toast.update(toastId, { render: error, type: 'error', autoClose: 5000 })
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
await updateItem({ ...updatedItem, parent: updatedItem.parent, position: undefined })
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const updateResult = await updatedItem.layer?.api?.updateItem!({
|
||||
id: updatedItem.id,
|
||||
parent: updatedItem.parent,
|
||||
position: null,
|
||||
})
|
||||
return updateResult as Item
|
||||
},
|
||||
toastId,
|
||||
'Item position updated',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Find the layer object by ID from server response
|
||||
const layer = layers.find((l) => l.id === (result.data!.layer as unknown as string))
|
||||
const itemWithLayer = { ...result.data, layer }
|
||||
updateItem(itemWithLayer)
|
||||
await linkItem(updatedItem.id)
|
||||
toast.update(toastId, {
|
||||
render: 'Item position updated',
|
||||
type: 'success',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
})
|
||||
setSelectPosition(null)
|
||||
setMarkerClicked(null)
|
||||
}
|
||||
@ -133,44 +160,25 @@ function useSelectPositionManager(): {
|
||||
}
|
||||
|
||||
const itemUpdatePosition = async (updatedItem: Item) => {
|
||||
let success = false
|
||||
const toastId = toast.loading('Updating item position')
|
||||
try {
|
||||
await updatedItem.layer?.api?.updateItem!({
|
||||
id: updatedItem.id,
|
||||
position: updatedItem.position,
|
||||
})
|
||||
success = true
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.update(toastId, {
|
||||
render: error.message,
|
||||
type: 'error',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
closeButton: true,
|
||||
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const updateResult = await updatedItem.layer?.api?.updateItem!({
|
||||
id: updatedItem.id,
|
||||
position: updatedItem.position,
|
||||
})
|
||||
} else if (typeof error === 'string') {
|
||||
toast.update(toastId, {
|
||||
render: error,
|
||||
type: 'error',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
closeButton: true,
|
||||
})
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
updateItem(updatedItem)
|
||||
toast.update(toastId, {
|
||||
render: 'Item position updated',
|
||||
type: 'success',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
closeButton: true,
|
||||
})
|
||||
return updateResult as Item
|
||||
},
|
||||
toastId,
|
||||
'Item position updated',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Find the layer object by ID from server response
|
||||
const layer = layers.find((l) => l.id === (result.data!.layer as unknown as string))
|
||||
const itemWithLayer = { ...result.data, layer }
|
||||
updateItem(itemWithLayer)
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,41 +190,21 @@ function useSelectPositionManager(): {
|
||||
newRelations.push({ items_id: markerClicked.id, related_items_id: id })
|
||||
const updatedItem = { id: markerClicked.id, relations: newRelations }
|
||||
|
||||
let success = false
|
||||
const toastId = toast.loading('Linking item')
|
||||
try {
|
||||
await markerClicked.layer?.api?.updateItem!(updatedItem)
|
||||
success = true
|
||||
} 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
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
updateItem({ ...markerClicked, relations: newRelations })
|
||||
toast.update(toastId, {
|
||||
render: 'Item linked',
|
||||
type: 'success',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
closeButton: true,
|
||||
})
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const updateResult = await markerClicked.layer?.api?.updateItem!(updatedItem)
|
||||
return updateResult as Item
|
||||
},
|
||||
toastId,
|
||||
'Item linked',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Find the layer object by ID from server response
|
||||
const layer = layers.find((l) => l.id === (result.data!.layer as unknown as string))
|
||||
const itemWithLayer = { ...result.data, layer }
|
||||
updateItem(itemWithLayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
import { linkItem } from './itemFunctions'
|
||||
@ -6,11 +7,15 @@ import type { Item } from '#types/Item'
|
||||
|
||||
const toastErrorMock: (t: string) => void = vi.fn()
|
||||
const toastSuccessMock: (t: string) => void = vi.fn()
|
||||
const toastLoadingMock: (t: string) => number = vi.fn(() => 123)
|
||||
const toastUpdateMock: (id: number, options: any) => void = vi.fn()
|
||||
|
||||
vi.mock('react-toastify', () => ({
|
||||
toast: {
|
||||
error: (t: string) => toastErrorMock(t),
|
||||
success: (t: string) => toastSuccessMock(t),
|
||||
loading: (t: string) => toastLoadingMock(t),
|
||||
update: (id: number, options: any) => toastUpdateMock(id, options),
|
||||
},
|
||||
}))
|
||||
|
||||
@ -19,6 +24,7 @@ describe('linkItem', () => {
|
||||
let updateApi: (item: Partial<Item>) => Promise<Item> = vi.fn()
|
||||
const item: Item = {
|
||||
layer: {
|
||||
id: 'test-layer-id',
|
||||
api: {
|
||||
updateItem: (item) => updateApi(item),
|
||||
getItems: vi.fn(),
|
||||
@ -66,7 +72,7 @@ describe('linkItem', () => {
|
||||
it('toasts an error', async () => {
|
||||
updateApi = vi.fn().mockRejectedValue('autsch')
|
||||
await linkItem(id, item, updateItem)
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('autsch')
|
||||
expect(toastUpdateMock).toHaveBeenCalledWith(123, expect.objectContaining({ type: 'error' }))
|
||||
expect(updateItem).not.toHaveBeenCalled()
|
||||
expect(toastSuccessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -74,10 +80,25 @@ describe('linkItem', () => {
|
||||
|
||||
describe('api resolves', () => {
|
||||
it('toasts success and calls updateItem()', async () => {
|
||||
const serverResponse = {
|
||||
...item,
|
||||
layer: 'test-layer-id',
|
||||
relations: [{ items_id: item.id, related_items_id: id }],
|
||||
}
|
||||
updateApi = vi.fn().mockResolvedValue(serverResponse)
|
||||
|
||||
await linkItem(id, item, updateItem)
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
expect(updateItem).toHaveBeenCalledTimes(1)
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith('Item linked')
|
||||
|
||||
expect(toastUpdateMock).toHaveBeenCalledWith(
|
||||
123,
|
||||
expect.objectContaining({ type: 'success' }),
|
||||
)
|
||||
expect(updateItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...serverResponse,
|
||||
layer: item.layer,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-optional-chain */
|
||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable no-catch-all/no-catch-all */
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { encodeTag } from '#utils/FormatTags'
|
||||
@ -18,6 +18,34 @@ import { randomColor } from '#utils/RandomColor'
|
||||
import type { FormState } from '#types/FormState'
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
// Handle API operations with consistent error handling and return response data
|
||||
const handleApiOperation = async (
|
||||
operation: () => Promise<Item>,
|
||||
toastId: string | number,
|
||||
successMessage: string,
|
||||
): Promise<{ success: boolean; data?: Item }> => {
|
||||
try {
|
||||
const data = await operation()
|
||||
toast.update(toastId, {
|
||||
render: successMessage,
|
||||
type: 'success',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
})
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
toast.update(toastId, {
|
||||
render: errorMessage,
|
||||
type: 'error',
|
||||
isLoading: false,
|
||||
autoClose: 5000,
|
||||
closeButton: true,
|
||||
})
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line promise/avoid-new
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@ -25,7 +53,6 @@ export const submitNewItem = async (
|
||||
evt: any,
|
||||
type: string,
|
||||
item,
|
||||
user,
|
||||
setLoading,
|
||||
tags,
|
||||
addTag,
|
||||
@ -60,18 +87,28 @@ export const submitNewItem = async (
|
||||
(l) => l.name.toLocaleLowerCase().replace('s', '') === addItemPopupType.toLocaleLowerCase(),
|
||||
)
|
||||
|
||||
let success = false
|
||||
try {
|
||||
await layer?.api?.createItem!({ ...formItem, id: uuid, type, parent: item.id })
|
||||
const toastId = toast.loading('Creating new item...')
|
||||
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const serverResult = await layer?.api?.createItem!({
|
||||
...formItem,
|
||||
id: uuid,
|
||||
type,
|
||||
parent: item.id,
|
||||
})
|
||||
return serverResult as Item
|
||||
},
|
||||
toastId,
|
||||
'New item created',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Find the layer object by ID from server response
|
||||
const layerForItem = layers.find((l) => l.id === result.data!.layer) || layer
|
||||
const itemWithLayer = { ...result.data, layer: layerForItem }
|
||||
addItem(itemWithLayer)
|
||||
await linkItem(uuid)
|
||||
success = true
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (error) {
|
||||
toast.error(error.toString())
|
||||
}
|
||||
if (success) {
|
||||
addItem({ ...formItem, id: uuid, type, layer, user_created: user, parent: item.id })
|
||||
toast.success('New item created')
|
||||
resetFilterTags()
|
||||
}
|
||||
setLoading(false)
|
||||
@ -83,17 +120,22 @@ export const linkItem = async (id: string, item: Item, updateItem) => {
|
||||
newRelations?.push({ items_id: item.id, related_items_id: id })
|
||||
const updatedItem = { id: item.id, relations: newRelations }
|
||||
|
||||
let success = false
|
||||
try {
|
||||
await item?.layer?.api?.updateItem!(updatedItem)
|
||||
success = true
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (error) {
|
||||
toast.error(error.toString())
|
||||
}
|
||||
if (success) {
|
||||
updateItem({ ...item, relations: newRelations })
|
||||
toast.success('Item linked')
|
||||
const toastId = toast.loading('Linking item...')
|
||||
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const serverResult = await item?.layer?.api?.updateItem!(updatedItem)
|
||||
return serverResult!
|
||||
},
|
||||
toastId,
|
||||
'Item linked',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Find the layer object by ID from server response or use existing layer
|
||||
const layer = item.layer
|
||||
const itemWithLayer = { ...result.data, layer, relations: newRelations }
|
||||
updateItem(itemWithLayer)
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,17 +143,22 @@ export const unlinkItem = async (id: string, item: Item, updateItem) => {
|
||||
const newRelations = item.relations?.filter((r) => r.related_items_id !== id)
|
||||
const updatedItem = { id: item.id, relations: newRelations }
|
||||
|
||||
let success = false
|
||||
try {
|
||||
await item?.layer?.api?.updateItem!(updatedItem)
|
||||
success = true
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (error) {
|
||||
toast.error(error.toString())
|
||||
}
|
||||
if (success) {
|
||||
updateItem({ ...item, relations: newRelations })
|
||||
toast.success('Item unlinked')
|
||||
const toastId = toast.loading('Unlinking item...')
|
||||
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const serverResult = await item?.layer?.api?.updateItem!(updatedItem)
|
||||
return serverResult!
|
||||
},
|
||||
toastId,
|
||||
'Item unlinked',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Find the layer object by ID from server response or use existing layer
|
||||
const layer = item.layer
|
||||
const itemWithLayer = { ...result.data, layer }
|
||||
updateItem(itemWithLayer)
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,23 +172,21 @@ export const handleDelete = async (
|
||||
) => {
|
||||
event.stopPropagation()
|
||||
setLoading(true)
|
||||
let success = false
|
||||
|
||||
try {
|
||||
await item.layer?.api?.deleteItem!(item.id)
|
||||
success = true
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (error) {
|
||||
toast.error(error.toString())
|
||||
}
|
||||
if (success) {
|
||||
removeItem(item)
|
||||
toast.success('Item deleted')
|
||||
|
||||
map.closePopup()
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
window.history.pushState({}, '', '/' + `${params ? `?${params}` : ''}`)
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
map.closePopup()
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
window.history.pushState({}, '', '/' + `${params ? `?${params}` : ''}`)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
export const onUpdateItem = async (
|
||||
@ -239,61 +284,52 @@ export const onUpdateItem = async (
|
||||
await sleep(200)
|
||||
|
||||
if (!item.new) {
|
||||
await (item?.layer?.api?.updateItem &&
|
||||
toast
|
||||
.promise(item?.layer?.api?.updateItem(changedItem), {
|
||||
pending: 'updating Item ...',
|
||||
success: 'Item updated',
|
||||
error: {
|
||||
render({ data }) {
|
||||
return `${data}`
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(setLoading(false))
|
||||
.then(
|
||||
() =>
|
||||
item &&
|
||||
updateItem({
|
||||
...item,
|
||||
...changedItem,
|
||||
markerIcon: state.marker_icon,
|
||||
gallery: state.gallery,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
setLoading(false)
|
||||
navigate(`/item/${item.id}${params && '?' + params}`)
|
||||
return null
|
||||
}))
|
||||
const toastId = toast.loading('updating Item ...')
|
||||
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const serverResult = await item?.layer?.api?.updateItem!(changedItem)
|
||||
return serverResult!
|
||||
},
|
||||
toastId,
|
||||
'Item updated',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Use server response with additional client-side data
|
||||
const itemWithLayer = {
|
||||
...result.data,
|
||||
layer: item.layer,
|
||||
markerIcon: state.marker_icon,
|
||||
gallery: state.gallery,
|
||||
}
|
||||
updateItem(itemWithLayer)
|
||||
navigate(`/item/${item.id}${params && '?' + params}`)
|
||||
}
|
||||
setLoading(false)
|
||||
} else {
|
||||
item.new = false
|
||||
await (item.layer?.api?.createItem &&
|
||||
toast
|
||||
.promise(item.layer?.api?.createItem(changedItem), {
|
||||
pending: 'updating Item ...',
|
||||
success: 'Item updated',
|
||||
error: {
|
||||
render({ data }) {
|
||||
return `${data}`
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(setLoading(false))
|
||||
.then(
|
||||
() =>
|
||||
item &&
|
||||
addItem({
|
||||
...item,
|
||||
...changedItem,
|
||||
layer: item.layer,
|
||||
user_created: user,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
setLoading(false)
|
||||
navigate(`/${params && '?' + params}`)
|
||||
return null
|
||||
}))
|
||||
const toastId = toast.loading('updating Item ...')
|
||||
|
||||
const result = await handleApiOperation(
|
||||
async () => {
|
||||
const serverResult = await item.layer?.api?.createItem!(changedItem)
|
||||
return serverResult!
|
||||
},
|
||||
toastId,
|
||||
'Item updated',
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Use server response with additional client-side data
|
||||
const itemWithLayer = {
|
||||
...result.data,
|
||||
layer: item.layer,
|
||||
user_created: user,
|
||||
}
|
||||
addItem(itemWithLayer)
|
||||
navigate(`/${params && '?' + params}`)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<dialog
|
||||
className={`${className ?? ''} card tw:shadow-xl tw:absolute tw:right-0 tw:top-0 tw:bottom-0 tw:left-0 tw:m-auto tw:transition-opacity tw:duration-300 tw:p-4 tw:max-w-xl tw:bg-base-100`}
|
||||
className={`${className ?? ''} tw:card tw:shadow-xl tw:absolute tw:right-0 tw:top-0 tw:bottom-0 tw:left-0 tw:m-auto tw:transition-opacity tw:duration-300 tw:p-4 tw:max-w-xl tw:bg-base-100`}
|
||||
ref={ref}
|
||||
onCancel={onClose}
|
||||
onClick={(e) =>
|
||||
ref.current && !isClickInsideRectangle(e, ref.current) && closeOnClickOutside && onClose()
|
||||
}
|
||||
>
|
||||
<div className='card-body tw:p-2'>
|
||||
<div className='tw:card-body tw:p-2'>
|
||||
<h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>{title}</h2>
|
||||
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
className='btn btn-sm btn-circle btn-ghost tw:absolute tw:right-2 tw:top-2'
|
||||
className='tw:btn tw:btn-sm tw:btn-circle tw:btn-ghost tw:absolute tw:right-2 tw:top-2'
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
|
||||
2
lib/src/types/LayerProps.d.ts
vendored
2
lib/src/types/LayerProps.d.ts
vendored
@ -7,7 +7,7 @@ import type { MarkerIcon } from './MarkerIcon'
|
||||
* @category Types
|
||||
*/
|
||||
export interface LayerProps {
|
||||
id?: string
|
||||
id: string
|
||||
data?: Item[]
|
||||
children?: React.ReactNode
|
||||
name: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user