Refactor ObjectForm and ApiServerContext for improved error handling and user feedback

- Introduced a new utility function, capitalizeFirstLetter, to standardize label formatting in ObjectForm.
- Enhanced error handling in ObjectForm by providing more descriptive error messages related to editing and fetching objects.
- Updated ApiServerContext to handle forbidden access scenarios with a dedicated modal, improving user experience during permission errors.
- Refactored state management in ObjectForm to streamline loading and editing states, ensuring better performance and clarity.
- Improved overall code readability and structure in both components.
This commit is contained in:
Tom Butcher 2026-08-20 18:26:40 +01:00
parent 76d00a8be1
commit 69d515efd9
2 changed files with 141 additions and 50 deletions

View File

@ -89,13 +89,20 @@ const getEditDisabled = (model, objectData, userProfile) => {
return false
}
if (typeof editAction.disabled === 'function') {
return (
editAction.disabled({ ...objectData, _user: userProfile }) ?? false
)
return editAction.disabled({ ...objectData, _user: userProfile }) ?? false
}
return !!editAction.disabled
}
const capitalizeFirstLetter = (value) => {
return String(value).charAt(0).toUpperCase() + String(value).slice(1)
}
const getModelMessageLabel = (model) => {
const modelObject = getModelByName(model)
return capitalizeFirstLetter(modelObject.label.toLowerCase())
}
/**
* ObjectForm is a reusable form component for editing any object type.
* It handles fetching, updating, locking, unlocking, and validation logic.
@ -110,9 +117,18 @@ const getEditDisabled = (model, objectData, userProfile) => {
* - setCurrentObject: boolean (sync object data to ActionsContext)
* - onStateChange: receives form state including editDisabled (from model edit action)
*/
const ObjectForm = forwardRef(
(
{ id, type, style, children, onEdit, onStateChange, setCurrentObject = false },
{
id,
type,
style,
children,
onEdit,
onStateChange,
setCurrentObject = false
},
ref
) => {
const [objectData, setObjectData] = useState(null)
@ -127,7 +143,7 @@ const ObjectForm = forwardRef(
const [form] = Form.useForm()
const formUpdateValues = Form.useWatch([], form)
const { showSuccess, showError: showMessageError } = useMessageContext()
const { showSuccess, showError } = useMessageContext()
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const [deleteLoading, setDeleteLoading] = useState(false)
const {
@ -137,7 +153,6 @@ const ObjectForm = forwardRef(
setObjectActivity,
clearObjectActivity,
fetchObjectActivities,
showError,
connected,
subscribeToObjectUpdates,
subscribeToObjectActivity,
@ -381,7 +396,9 @@ const ObjectForm = forwardRef(
releaseEditingState()
clearAction()
showMessageError('Another user is already editing this item')
showError(
`Another user is already editing this ${getModelMessageLabel(type).toLowerCase()}.`
)
},
[
id,
@ -390,7 +407,7 @@ const ObjectForm = forwardRef(
setObjectActivity,
releaseEditingState,
clearAction,
showMessageError
showError
]
)
@ -411,7 +428,11 @@ const ObjectForm = forwardRef(
onStateChangeRef.current({
formValid: true,
objectData: mergedObjectData,
editDisabled: getEditDisabled(model, mergedObjectData, userProfile)
editDisabled: getEditDisabled(
model,
mergedObjectData,
userProfile
)
})
})
.catch(() => {
@ -419,7 +440,11 @@ const ObjectForm = forwardRef(
onStateChangeRef.current({
formValid: false,
objectData: mergedObjectData,
editDisabled: getEditDisabled(model, mergedObjectData, userProfile)
editDisabled: getEditDisabled(
model,
mergedObjectData,
userProfile
)
})
})
}, 150)
@ -486,10 +511,8 @@ const ObjectForm = forwardRef(
onStateChangeRef.current({ loading: false })
} catch (err) {
console.error(err)
showMessageError('Failed to fetch object info')
showError(
`Failed to fetch object information. Message: ${err.message}. Code: ${err.code}`,
fetchObject
`Failed to fetch ${getModelMessageLabel(type).toLowerCase()} info.`
)
}
}, [
@ -498,7 +521,6 @@ const ObjectForm = forwardRef(
id,
type,
form,
showMessageError,
showError,
calculateComputedValues,
model,
@ -662,14 +684,18 @@ const ObjectForm = forwardRef(
if (getBeingEditedByOther(latestActivities, userProfile?._id)) {
clearAction()
showMessageError('Another user is already editing this item')
showError(
`Another user is already editing this ${getModelMessageLabel(type).toLowerCase()}.`
)
return
}
const activityResult = await setObjectActivity(id, type, 'editing')
if (activityResult?.success === false) {
clearAction()
showMessageError('Another user is already editing this item')
showError(
`Another user is already editing this ${getModelMessageLabel(type).toLowerCase()}.`
)
return
}
@ -695,7 +721,9 @@ const ObjectForm = forwardRef(
})
} catch (err) {
console.error(err)
showMessageError('Failed to start editing')
showError(
`Failed to start editing ${getModelMessageLabel(type).toLowerCase()}.`
)
}
}
@ -705,8 +733,12 @@ const ObjectForm = forwardRef(
}
const handleUpdate = async () => {
try {
const value = await form.validateFields()
let error
const value = await form.validateFields().catch((err) => {
error = err
})
if (!error) {
setEditLoading(true)
const currentFormData = {
@ -714,32 +746,37 @@ const ObjectForm = forwardRef(
...value
}
onStateChangeRef.current({ editLoading: true })
await updateObject(id, type, currentFormData)
setIsEditing(false)
isEditingRef.current = false
onStateChangeRef.current({ isEditing: isEditingRef.current })
setObjectData({
...objectData,
...currentFormData,
_isEditing: isEditingRef.current
})
setObjectActivity(id, type, 'viewing')
showSuccess('Information updated successfully')
} catch (err) {
console.error(err)
if (err.errorFields) {
return
const updatedObject = await updateObject(id, type, currentFormData)
if (updatedObject.error) {
console.log('THERE IS AN ERROR')
error = updatedObject
} else {
setIsEditing(false)
isEditingRef.current = false
onStateChangeRef.current({ isEditing: isEditingRef.current })
setObjectData({
...objectData,
...currentFormData,
...updatedObject,
_isEditing: isEditingRef.current
})
setObjectActivity(id, type, 'viewing')
showSuccess(`${getModelMessageLabel(type)} edited successfully!`)
}
showMessageError('Failed to update information')
showError(
`Failed to update information. Message: ${err.message}. Code: ${err.code}`,
() => handleUpdate()
)
} finally {
handleFetchObject()
setEditLoading(false)
onStateChangeRef.current({ editLoading: false })
}
if (error) {
console.log('ERROR', error)
showError(
`Failed to update ${getModelMessageLabel(type).toLowerCase()} information.`
)
}
handleFetchObject()
setEditLoading(false)
onStateChangeRef.current({ editLoading: false })
}
const handleDelete = () => {
@ -751,15 +788,13 @@ const ObjectForm = forwardRef(
try {
await deleteObject(id, type)
setDeleteModalOpen(false)
showSuccess('Deleted successfully')
showSuccess(`${getModelMessageLabel(type)} deleted successfully!`)
navigate(-2)
// Optionally: trigger a callback to parent to remove this object from view
} catch (err) {
console.error(err)
showMessageError('Failed to delete')
showError(
`Failed to delete. Message: ${err.message}. Code: ${err.code}`,
confirmDelete
`Failed to delete ${getModelMessageLabel(type).toLowerCase()}.`
)
} finally {
setDeleteLoading(false)

View File

@ -8,7 +8,7 @@ import {
useCallback
} from 'react'
import io from 'socket.io-client'
import { message, Modal, Space, Button } from 'antd'
import { message, Modal, Space, Button, Typography } from 'antd'
import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext'
import { useLocation, useNavigate } from 'react-router-dom'
@ -16,12 +16,15 @@ import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import LockIcon from '../../Icons/LockIcon'
import config from '../../../config'
import loglevel from 'loglevel'
import { getModelByName } from '../../../database/ObjectModels'
const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel)
const { Text } = Typography
const SPOTLIGHT_CACHE_TTL_MS = 10_000
const spotlightCache = new Map()
@ -105,6 +108,8 @@ const ApiServerProvider = ({ children }) => {
const [showErrorModal, setShowErrorModal] = useState(false)
const [errorModalContent, setErrorModalContent] = useState('')
const [retryCallback, setRetryCallback] = useState(null)
const [showForbiddenModal, setShowForbiddenModal] = useState(false)
const [forbiddenIsGet, setForbiddenIsGet] = useState(false)
const [userSettings, setUserSettings] = useState(createEmptyUserSettings)
const [userSettingsLoaded, setUserSettingsLoaded] = useState(false)
const subscribedCallbacksRef = useRef(new Map())
@ -1030,6 +1035,19 @@ const ApiServerProvider = ({ children }) => {
setUnauthenticated()
return
}
if (code == 'FORBIDDEN') {
const method = (
error.config?.method ||
error.response?.config?.method ||
error.response?.data?.method ||
''
)
.toString()
.toLowerCase()
setForbiddenIsGet(method === 'get')
setShowForbiddenModal(true)
return
}
var content = `Error ${error.code} (${error.status}): ${error.message}`
if (error.response?.data?.error) {
content = `${error.response?.data?.error} (${error.status})`
@ -1050,6 +1068,15 @@ const ApiServerProvider = ({ children }) => {
setRetryCallback(null)
}
const handleForbiddenOk = () => {
setShowForbiddenModal(false)
}
const handleForbiddenBack = () => {
setShowForbiddenModal(false)
navigate(-1)
}
// Generalized fetchObject function
const fetchObject = async (id, type) => {
const fetchUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}`
@ -1282,10 +1309,10 @@ const ApiServerProvider = ({ children }) => {
return response.data
} catch (err) {
console.error(err)
setError(err, () => {
showError(err, () => {
updateObject(id, type, value)
})
return {}
return { error: err.response.data.error }
}
}
@ -2447,6 +2474,35 @@ const ApiServerProvider = ({ children }) => {
>
{errorModalContent}
</Modal>
<Modal
title={
<Space size={'middle'}>
<LockIcon />
Access Denied
</Space>
}
open={showForbiddenModal}
style={{ maxWidth: 430 }}
closable={false}
centered
maskClosable={!forbiddenIsGet}
onCancel={forbiddenIsGet ? handleForbiddenBack : handleForbiddenOk}
footer={[
<Button
key={forbiddenIsGet ? 'back' : 'ok'}
type='default'
onClick={forbiddenIsGet ? handleForbiddenBack : handleForbiddenOk}
>
{forbiddenIsGet ? 'Back' : 'OK'}
</Button>
]}
>
<Text>
{forbiddenIsGet
? 'You do not have permission to view this page.'
: 'You do not have permission to perform this action.'}
</Text>
</Modal>
</ApiServerContext.Provider>
)
}