refactor(lib): implement server-response-first pattern (#322)

* use server response for local state updates

* fix formatting

* refactor: comprehensive server-response-first pattern implementation

## Major Changes

### LayerProps ID Required
- Made `LayerProps.id` required (was optional)
- All layers now guaranteed to have server-provided UUID
- Enables reliable layer ID mapping from server responses

### useSelectPosition Hook Refactored
- Added reusable `handleApiOperation` helper function
- Refactored `itemUpdatePosition`, `itemUpdateParent`, `linkItem`
- All functions now use server response + layer ID mapping
- Consistent error handling and toast management

### itemFunctions.ts Complete Refactor
- **submitNewItem**: Server response with layer mapping
- **linkItem**: Server response preserves layer object
- **unlinkItem**: Same pattern as linkItem
- **handleDelete**: Simplified error handling
- **onUpdateItem**: Complex function refactored for both update/create branches

### Benefits
-  Eliminates race conditions from manual state construction
-  Server response as single source of truth for all updates
-  Consistent error handling across all API operations
-  Items no longer disappear from map after updates
-  Type-safe layer ID mapping

### Testing
- Updated ItemFunctions.spec.tsx with new toast patterns
- Added required layer IDs to test objects
- All 19 tests passing (3 skipped)
- ESLint clean

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix linting

* fix: resolve TypeScript undefined data errors

- Add non-null assertions for result.data in conditional blocks
- TypeScript now properly recognizes data is defined after success check
- All linting and TypeScript errors resolved

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fixed examples

* remove unneccessary uuid generation

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Anton Tranelis 2025-08-20 15:03:30 +02:00 committed by GitHub
parent 94fa6321ba
commit 649efe551d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 408 additions and 283 deletions

View File

@ -2,6 +2,7 @@ import { UtopiaMap, Layer } from "utopia-ui"
import { events, places } from "./sample-data" import { events, places } from "./sample-data"
const itemTypeEvent = { const itemTypeEvent = {
id: "a6dbf1a7-adf2-4ff5-8e20-d3aad66635fb",
name: "event", name: "event",
show_name_input: false, show_name_input: false,
show_profile_button: false, show_profile_button: false,
@ -39,6 +40,7 @@ function App() {
return ( return (
<UtopiaMap center={[50.6, 15.5]} zoom={5} height='100dvh' width="100dvw"> <UtopiaMap center={[50.6, 15.5]} zoom={5} height='100dvh' width="100dvw">
<Layer <Layer
id="8b6892ea-4ca3-4b86-8060-b0371a8dd375"
name='events' name='events'
markerIcon={ markerIcon={
{image: "calendar.svg", {image: "calendar.svg",
@ -54,6 +56,7 @@ function App() {
itemType={itemTypeEvent} itemType={itemTypeEvent}
/> />
<Layer <Layer
id="eea49637-1232-42f9-aec9-77b3187d5d7c"
name='places' name='places'
markerIcon={ markerIcon={
{image: "point.svg"} {image: "point.svg"}

View File

@ -5,12 +5,14 @@ function App() {
return ( return (
<UtopiaMap center={[50.6, 15.5]} zoom={5} height='100dvh' width="100dvw"> <UtopiaMap center={[50.6, 15.5]} zoom={5} height='100dvh' width="100dvw">
<Layer <Layer
id="eea49637-1232-42f9-aec9-77b3187d5d7c"
name='events' name='events'
markerIcon='calendar' markerIcon='calendar'
markerShape='square' markerShape='square'
markerDefaultColor='#700' markerDefaultColor='#700'
data={events} /> data={events} />
<Layer <Layer
id="9b880bc6-2ad0-439a-b3b6-e7907d1d824a"
name='places' name='places'
markerIcon='point' markerIcon='point'
markerShape='circle' markerShape='circle'

View File

@ -17,6 +17,7 @@ export type { Popup } from 'leaflet'
* @category Map * @category Map
*/ */
export const Layer = ({ export const Layer = ({
id,
data, data,
children, children,
name = 'places', name = 'places',
@ -46,6 +47,7 @@ export const Layer = ({
useEffect(() => { useEffect(() => {
data && data &&
setItemsData({ setItemsData({
id,
data, data,
children, children,
name, name,
@ -68,6 +70,7 @@ export const Layer = ({
}) })
api && api &&
setItemsApi({ setItemsApi({
id,
data, data,
children, children,
name, name,

View File

@ -1,11 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-misused-promises */ /* 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-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable no-catch-all/no-catch-all */
import { useContext, useEffect, useRef, useState } from 'react'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Popup as LeafletPopup, useMap } from 'react-leaflet' import { Popup as LeafletPopup, useMap } from 'react-leaflet'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
@ -50,95 +47,170 @@ export function ItemFormPopup(props: Props) {
const { user } = useAuth() const { user } = useAuth()
const handleSubmit = async (evt: any) => { // Extract form data into Item object
if (!popupForm) { const parseFormData = useCallback(
throw new Error('Popup form is not defined') (evt: React.FormEvent<HTMLFormElement>): Item => {
} if (!popupForm) {
const formItem: Item = {} as Item throw new Error('Popup form is not defined')
Array.from(evt.target).forEach((input: HTMLInputElement) => {
if (input.name) {
formItem[input.name] = input.value
} }
})
formItem.position = { const formItem: Item = {} as Item
type: 'Point', const formData = new FormData(evt.currentTarget)
coordinates: [popupForm.position.lng, popupForm.position.lat],
} 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() evt.preventDefault()
const name = formItem.name ? formItem.name : user?.first_name if (!popupForm) {
if (!name) { throw new Error('Popup form is not defined')
toast.error('Name is must be defined')
return
} }
setSpinner(true) setSpinner(true)
formItem.text && try {
formItem.text const formItem = parseFormData(evt)
.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
})
if (popupForm.item) { // Process hashtags if text exists
let success = false if (formItem.text) {
try { processHashtags(formItem.text)
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())
} }
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: boolean
let success = false if (popupForm.item) {
try { success = await handleUpdateItem(formItem)
popupForm.layer.userProfileLayer && } else {
item && success = await handleCreateItem(formItem)
(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())
} }
if (success) { if (success) {
if (popupForm.layer.userProfileLayer && item) updateItem({ ...item, ...formItem }) map.closePopup()
if (!popupForm.layer.userProfileLayer || !item) { setPopupForm(null)
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()
} }
} finally {
setSpinner(false) setSpinner(false)
map.closePopup()
} }
setPopupForm(null)
} }
const resetPopup = () => { const resetPopup = () => {

View File

@ -3,14 +3,16 @@
/* eslint-disable @typescript-eslint/no-floating-promises */ /* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/await-thenable */
/* eslint-disable @typescript-eslint/restrict-plus-operands */ /* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* 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 { toast } from 'react-toastify'
import { useUpdateItem } from './useItems' import { useUpdateItem } from './useItems'
import { useLayers } from './useLayers'
import { useHasUserPermission } from './usePermissions' import { useHasUserPermission } from './usePermissions'
import type { Item } from '#types/Item' import type { Item } from '#types/Item'
@ -44,6 +46,38 @@ function useSelectPositionManager(): {
const [mapClicked, setMapClicked] = useState<PolygonClickedProps>() const [mapClicked, setMapClicked] = useState<PolygonClickedProps>()
const updateItem = useUpdateItem() const updateItem = useUpdateItem()
const hasUserPermission = useHasUserPermission() 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(() => { useEffect(() => {
if ( if (
@ -91,32 +125,25 @@ function useSelectPositionManager(): {
markerClicked?.layer?.api?.collectionName && markerClicked?.layer?.api?.collectionName &&
hasUserPermission(markerClicked.layer.api.collectionName, 'update', markerClicked) hasUserPermission(markerClicked.layer.api.collectionName, 'update', markerClicked)
) { ) {
let success = false const result = await handleApiOperation(
try { async () => {
await updatedItem.layer?.api?.updateItem!({ const updateResult = await updatedItem.layer?.api?.updateItem!({
id: updatedItem.id, id: updatedItem.id,
parent: updatedItem.parent, parent: updatedItem.parent,
position: null, position: null,
}) })
success = true return updateResult as Item
} catch (error: unknown) { },
if (error instanceof Error) { toastId,
toast.update(toastId, { render: error.message, type: 'error', autoClose: 5000 }) 'Item position updated',
} else if (typeof error === 'string') { )
toast.update(toastId, { render: error, type: 'error', autoClose: 5000 })
} else { if (result.success && result.data) {
throw error // 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 }
if (success) { updateItem(itemWithLayer)
await updateItem({ ...updatedItem, parent: updatedItem.parent, position: undefined })
await linkItem(updatedItem.id) await linkItem(updatedItem.id)
toast.update(toastId, {
render: 'Item position updated',
type: 'success',
isLoading: false,
autoClose: 5000,
})
setSelectPosition(null) setSelectPosition(null)
setMarkerClicked(null) setMarkerClicked(null)
} }
@ -133,44 +160,25 @@ function useSelectPositionManager(): {
} }
const itemUpdatePosition = async (updatedItem: Item) => { const itemUpdatePosition = async (updatedItem: Item) => {
let success = false
const toastId = toast.loading('Updating item position') const toastId = toast.loading('Updating item position')
try {
await updatedItem.layer?.api?.updateItem!({ const result = await handleApiOperation(
id: updatedItem.id, async () => {
position: updatedItem.position, const updateResult = await updatedItem.layer?.api?.updateItem!({
}) id: updatedItem.id,
success = true position: updatedItem.position,
} 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') { return updateResult as Item
toast.update(toastId, { },
render: error, toastId,
type: 'error', 'Item position updated',
isLoading: false, )
autoClose: 5000,
closeButton: true, if (result.success && result.data) {
}) // Find the layer object by ID from server response
} else { const layer = layers.find((l) => l.id === (result.data!.layer as unknown as string))
throw error const itemWithLayer = { ...result.data, layer }
} updateItem(itemWithLayer)
}
if (success) {
updateItem(updatedItem)
toast.update(toastId, {
render: 'Item position updated',
type: 'success',
isLoading: false,
autoClose: 5000,
closeButton: true,
})
} }
} }
@ -182,41 +190,21 @@ function useSelectPositionManager(): {
newRelations.push({ items_id: markerClicked.id, related_items_id: id }) newRelations.push({ items_id: markerClicked.id, related_items_id: id })
const updatedItem = { id: markerClicked.id, relations: newRelations } const updatedItem = { id: markerClicked.id, relations: newRelations }
let success = false
const toastId = toast.loading('Linking item') const toastId = toast.loading('Linking item')
try { const result = await handleApiOperation(
await markerClicked.layer?.api?.updateItem!(updatedItem) async () => {
success = true const updateResult = await markerClicked.layer?.api?.updateItem!(updatedItem)
} catch (error: unknown) { return updateResult as Item
if (error instanceof Error) { },
toast.update(toastId, { toastId,
render: error.message, 'Item linked',
type: 'error', )
isLoading: false,
autoClose: 5000, if (result.success && result.data) {
closeButton: true, // Find the layer object by ID from server response
}) const layer = layers.find((l) => l.id === (result.data!.layer as unknown as string))
} else if (typeof error === 'string') { const itemWithLayer = { ...result.data, layer }
toast.update(toastId, { updateItem(itemWithLayer)
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,
})
} }
} }
} }

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { linkItem } from './itemFunctions' import { linkItem } from './itemFunctions'
@ -6,11 +7,15 @@ import type { Item } from '#types/Item'
const toastErrorMock: (t: string) => void = vi.fn() const toastErrorMock: (t: string) => void = vi.fn()
const toastSuccessMock: (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', () => ({ vi.mock('react-toastify', () => ({
toast: { toast: {
error: (t: string) => toastErrorMock(t), error: (t: string) => toastErrorMock(t),
success: (t: string) => toastSuccessMock(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() let updateApi: (item: Partial<Item>) => Promise<Item> = vi.fn()
const item: Item = { const item: Item = {
layer: { layer: {
id: 'test-layer-id',
api: { api: {
updateItem: (item) => updateApi(item), updateItem: (item) => updateApi(item),
getItems: vi.fn(), getItems: vi.fn(),
@ -66,7 +72,7 @@ describe('linkItem', () => {
it('toasts an error', async () => { it('toasts an error', async () => {
updateApi = vi.fn().mockRejectedValue('autsch') updateApi = vi.fn().mockRejectedValue('autsch')
await linkItem(id, item, updateItem) await linkItem(id, item, updateItem)
expect(toastErrorMock).toHaveBeenCalledWith('autsch') expect(toastUpdateMock).toHaveBeenCalledWith(123, expect.objectContaining({ type: 'error' }))
expect(updateItem).not.toHaveBeenCalled() expect(updateItem).not.toHaveBeenCalled()
expect(toastSuccessMock).not.toHaveBeenCalled() expect(toastSuccessMock).not.toHaveBeenCalled()
}) })
@ -74,10 +80,25 @@ describe('linkItem', () => {
describe('api resolves', () => { describe('api resolves', () => {
it('toasts success and calls updateItem()', async () => { 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) await linkItem(id, item, updateItem)
expect(toastErrorMock).not.toHaveBeenCalled()
expect(updateItem).toHaveBeenCalledTimes(1) expect(toastUpdateMock).toHaveBeenCalledWith(
expect(toastSuccessMock).toHaveBeenCalledWith('Item linked') 123,
expect.objectContaining({ type: 'success' }),
)
expect(updateItem).toHaveBeenCalledWith(
expect.objectContaining({
...serverResponse,
layer: item.layer,
}),
)
}) })
}) })
}) })

View File

@ -3,12 +3,12 @@
/* eslint-disable @typescript-eslint/prefer-optional-chain */ /* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/restrict-plus-operands */ /* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/restrict-template-expressions */ /* 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-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable no-catch-all/no-catch-all */
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { encodeTag } from '#utils/FormatTags' import { encodeTag } from '#utils/FormatTags'
@ -18,6 +18,34 @@ import { randomColor } from '#utils/RandomColor'
import type { FormState } from '#types/FormState' import type { FormState } from '#types/FormState'
import type { Item } from '#types/Item' 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 // eslint-disable-next-line promise/avoid-new
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@ -25,7 +53,6 @@ export const submitNewItem = async (
evt: any, evt: any,
type: string, type: string,
item, item,
user,
setLoading, setLoading,
tags, tags,
addTag, addTag,
@ -60,18 +87,28 @@ export const submitNewItem = async (
(l) => l.name.toLocaleLowerCase().replace('s', '') === addItemPopupType.toLocaleLowerCase(), (l) => l.name.toLocaleLowerCase().replace('s', '') === addItemPopupType.toLocaleLowerCase(),
) )
let success = false const toastId = toast.loading('Creating new item...')
try {
await layer?.api?.createItem!({ ...formItem, id: uuid, type, parent: item.id }) 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) 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() resetFilterTags()
} }
setLoading(false) 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 }) newRelations?.push({ items_id: item.id, related_items_id: id })
const updatedItem = { id: item.id, relations: newRelations } const updatedItem = { id: item.id, relations: newRelations }
let success = false const toastId = toast.loading('Linking item...')
try {
await item?.layer?.api?.updateItem!(updatedItem) const result = await handleApiOperation(
success = true async () => {
// eslint-disable-next-line no-catch-all/no-catch-all const serverResult = await item?.layer?.api?.updateItem!(updatedItem)
} catch (error) { return serverResult!
toast.error(error.toString()) },
} toastId,
if (success) { 'Item linked',
updateItem({ ...item, relations: newRelations }) )
toast.success('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 newRelations = item.relations?.filter((r) => r.related_items_id !== id)
const updatedItem = { id: item.id, relations: newRelations } const updatedItem = { id: item.id, relations: newRelations }
let success = false const toastId = toast.loading('Unlinking item...')
try {
await item?.layer?.api?.updateItem!(updatedItem) const result = await handleApiOperation(
success = true async () => {
// eslint-disable-next-line no-catch-all/no-catch-all const serverResult = await item?.layer?.api?.updateItem!(updatedItem)
} catch (error) { return serverResult!
toast.error(error.toString()) },
} toastId,
if (success) { 'Item unlinked',
updateItem({ ...item, relations: newRelations }) )
toast.success('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() event.stopPropagation()
setLoading(true) setLoading(true)
let success = false
try { try {
await item.layer?.api?.deleteItem!(item.id) 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) removeItem(item)
toast.success('Item deleted') 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) setLoading(false)
map.closePopup()
const params = new URLSearchParams(window.location.search)
window.history.pushState({}, '', '/' + `${params ? `?${params}` : ''}`)
navigate('/')
} }
export const onUpdateItem = async ( export const onUpdateItem = async (
@ -239,61 +284,52 @@ export const onUpdateItem = async (
await sleep(200) await sleep(200)
if (!item.new) { if (!item.new) {
await (item?.layer?.api?.updateItem && const toastId = toast.loading('updating Item ...')
toast
.promise(item?.layer?.api?.updateItem(changedItem), { const result = await handleApiOperation(
pending: 'updating Item ...', async () => {
success: 'Item updated', const serverResult = await item?.layer?.api?.updateItem!(changedItem)
error: { return serverResult!
render({ data }) { },
return `${data}` toastId,
}, 'Item updated',
}, )
})
.catch(setLoading(false)) if (result.success && result.data) {
.then( // Use server response with additional client-side data
() => const itemWithLayer = {
item && ...result.data,
updateItem({ layer: item.layer,
...item, markerIcon: state.marker_icon,
...changedItem, gallery: state.gallery,
markerIcon: state.marker_icon, }
gallery: state.gallery, updateItem(itemWithLayer)
}), navigate(`/item/${item.id}${params && '?' + params}`)
) }
.then(() => { setLoading(false)
setLoading(false)
navigate(`/item/${item.id}${params && '?' + params}`)
return null
}))
} else { } else {
item.new = false item.new = false
await (item.layer?.api?.createItem && const toastId = toast.loading('updating Item ...')
toast
.promise(item.layer?.api?.createItem(changedItem), { const result = await handleApiOperation(
pending: 'updating Item ...', async () => {
success: 'Item updated', const serverResult = await item.layer?.api?.createItem!(changedItem)
error: { return serverResult!
render({ data }) { },
return `${data}` toastId,
}, 'Item updated',
}, )
})
.catch(setLoading(false)) if (result.success && result.data) {
.then( // Use server response with additional client-side data
() => const itemWithLayer = {
item && ...result.data,
addItem({ layer: item.layer,
...item, user_created: user,
...changedItem, }
layer: item.layer, addItem(itemWithLayer)
user_created: user, navigate(`/${params && '?' + params}`)
}), }
) setLoading(false)
.then(() => {
setLoading(false)
navigate(`/${params && '?' + params}`)
return null
}))
} }
} }

View File

@ -7,7 +7,7 @@ import type { MarkerIcon } from './MarkerIcon'
* @category Types * @category Types
*/ */
export interface LayerProps { export interface LayerProps {
id?: string id: string
data?: Item[] data?: Item[]
children?: React.ReactNode children?: React.ReactNode
name: string name: string