mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
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:
parent
94fa6321ba
commit
649efe551d
@ -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"}
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,96 +47,171 @@ export function ItemFormPopup(props: Props) {
|
|||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
const handleSubmit = async (evt: any) => {
|
// Extract form data into Item object
|
||||||
|
const parseFormData = useCallback(
|
||||||
|
(evt: React.FormEvent<HTMLFormElement>): Item => {
|
||||||
if (!popupForm) {
|
if (!popupForm) {
|
||||||
throw new Error('Popup form is not defined')
|
throw new Error('Popup form is not defined')
|
||||||
}
|
}
|
||||||
|
|
||||||
const formItem: Item = {} as Item
|
const formItem: Item = {} as Item
|
||||||
Array.from(evt.target).forEach((input: HTMLInputElement) => {
|
const formData = new FormData(evt.currentTarget)
|
||||||
if (input.name) {
|
|
||||||
formItem[input.name] = input.value
|
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 = {
|
formItem.position = {
|
||||||
type: 'Point',
|
type: 'Point',
|
||||||
coordinates: [popupForm.position.lng, popupForm.position.lat],
|
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
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Process hashtags if text exists
|
||||||
|
if (formItem.text) {
|
||||||
|
processHashtags(formItem.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
let success: boolean
|
||||||
if (popupForm.item) {
|
if (popupForm.item) {
|
||||||
let success = false
|
success = await handleUpdateItem(formItem)
|
||||||
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())
|
|
||||||
}
|
|
||||||
if (success) {
|
|
||||||
updateItem({ ...popupForm.item, ...formItem })
|
|
||||||
toast.success('Item updated')
|
|
||||||
}
|
|
||||||
setSpinner(false)
|
|
||||||
map.closePopup()
|
|
||||||
} else {
|
} else {
|
||||||
const item = items.find((i) => i.user_created?.id === user?.id && i.layer === popupForm.layer)
|
success = await handleCreateItem(formItem)
|
||||||
|
}
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
if (success) {
|
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()
|
|
||||||
}
|
|
||||||
setSpinner(false)
|
|
||||||
map.closePopup()
|
map.closePopup()
|
||||||
}
|
|
||||||
setPopupForm(null)
|
setPopupForm(null)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setSpinner(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetPopup = () => {
|
const resetPopup = () => {
|
||||||
if (formRef.current) {
|
if (formRef.current) {
|
||||||
|
|||||||
@ -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(
|
||||||
|
async () => {
|
||||||
|
const updateResult = await updatedItem.layer?.api?.updateItem!({
|
||||||
id: updatedItem.id,
|
id: updatedItem.id,
|
||||||
position: updatedItem.position,
|
position: updatedItem.position,
|
||||||
})
|
})
|
||||||
success = true
|
return updateResult as Item
|
||||||
} catch (error: unknown) {
|
},
|
||||||
if (error instanceof Error) {
|
toastId,
|
||||||
toast.update(toastId, {
|
'Item position updated',
|
||||||
render: error.message,
|
)
|
||||||
type: 'error',
|
|
||||||
isLoading: false,
|
if (result.success && result.data) {
|
||||||
autoClose: 5000,
|
// Find the layer object by ID from server response
|
||||||
closeButton: true,
|
const layer = layers.find((l) => l.id === (result.data!.layer as unknown as string))
|
||||||
})
|
const itemWithLayer = { ...result.data, layer }
|
||||||
} else if (typeof error === 'string') {
|
updateItem(itemWithLayer)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
map.closePopup()
|
map.closePopup()
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
window.history.pushState({}, '', '/' + `${params ? `?${params}` : ''}`)
|
window.history.pushState({}, '', '/' + `${params ? `?${params}` : ''}`)
|
||||||
navigate('/')
|
navigate('/')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
)
|
||||||
.then(
|
|
||||||
() =>
|
if (result.success && result.data) {
|
||||||
item &&
|
// Use server response with additional client-side data
|
||||||
updateItem({
|
const itemWithLayer = {
|
||||||
...item,
|
...result.data,
|
||||||
...changedItem,
|
layer: item.layer,
|
||||||
markerIcon: state.marker_icon,
|
markerIcon: state.marker_icon,
|
||||||
gallery: state.gallery,
|
gallery: state.gallery,
|
||||||
}),
|
}
|
||||||
)
|
updateItem(itemWithLayer)
|
||||||
.then(() => {
|
|
||||||
setLoading(false)
|
|
||||||
navigate(`/item/${item.id}${params && '?' + params}`)
|
navigate(`/item/${item.id}${params && '?' + params}`)
|
||||||
return null
|
}
|
||||||
}))
|
setLoading(false)
|
||||||
} 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))
|
)
|
||||||
.then(
|
|
||||||
() =>
|
if (result.success && result.data) {
|
||||||
item &&
|
// Use server response with additional client-side data
|
||||||
addItem({
|
const itemWithLayer = {
|
||||||
...item,
|
...result.data,
|
||||||
...changedItem,
|
|
||||||
layer: item.layer,
|
layer: item.layer,
|
||||||
user_created: user,
|
user_created: user,
|
||||||
}),
|
}
|
||||||
)
|
addItem(itemWithLayer)
|
||||||
.then(() => {
|
|
||||||
setLoading(false)
|
|
||||||
navigate(`/${params && '?' + params}`)
|
navigate(`/${params && '?' + params}`)
|
||||||
return null
|
}
|
||||||
}))
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
* @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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user