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

View File

@ -8,7 +8,7 @@ import {
useCallback useCallback
} from 'react' } from 'react'
import io from 'socket.io-client' 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 PropTypes from 'prop-types'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@ -16,12 +16,15 @@ import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon' import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import LockIcon from '../../Icons/LockIcon'
import config from '../../../config' import config from '../../../config'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
const logger = loglevel.getLogger('ApiServerContext') const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
const { Text } = Typography
const SPOTLIGHT_CACHE_TTL_MS = 10_000 const SPOTLIGHT_CACHE_TTL_MS = 10_000
const spotlightCache = new Map() const spotlightCache = new Map()
@ -105,6 +108,8 @@ const ApiServerProvider = ({ children }) => {
const [showErrorModal, setShowErrorModal] = useState(false) const [showErrorModal, setShowErrorModal] = useState(false)
const [errorModalContent, setErrorModalContent] = useState('') const [errorModalContent, setErrorModalContent] = useState('')
const [retryCallback, setRetryCallback] = useState(null) const [retryCallback, setRetryCallback] = useState(null)
const [showForbiddenModal, setShowForbiddenModal] = useState(false)
const [forbiddenIsGet, setForbiddenIsGet] = useState(false)
const [userSettings, setUserSettings] = useState(createEmptyUserSettings) const [userSettings, setUserSettings] = useState(createEmptyUserSettings)
const [userSettingsLoaded, setUserSettingsLoaded] = useState(false) const [userSettingsLoaded, setUserSettingsLoaded] = useState(false)
const subscribedCallbacksRef = useRef(new Map()) const subscribedCallbacksRef = useRef(new Map())
@ -1030,6 +1035,19 @@ const ApiServerProvider = ({ children }) => {
setUnauthenticated() setUnauthenticated()
return 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}` var content = `Error ${error.code} (${error.status}): ${error.message}`
if (error.response?.data?.error) { if (error.response?.data?.error) {
content = `${error.response?.data?.error} (${error.status})` content = `${error.response?.data?.error} (${error.status})`
@ -1050,6 +1068,15 @@ const ApiServerProvider = ({ children }) => {
setRetryCallback(null) setRetryCallback(null)
} }
const handleForbiddenOk = () => {
setShowForbiddenModal(false)
}
const handleForbiddenBack = () => {
setShowForbiddenModal(false)
navigate(-1)
}
// Generalized fetchObject function // Generalized fetchObject function
const fetchObject = async (id, type) => { const fetchObject = async (id, type) => {
const fetchUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}` const fetchUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}`
@ -1282,10 +1309,10 @@ const ApiServerProvider = ({ children }) => {
return response.data return response.data
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError(err, () => { showError(err, () => {
updateObject(id, type, value) updateObject(id, type, value)
}) })
return {} return { error: err.response.data.error }
} }
} }
@ -2447,6 +2474,35 @@ const ApiServerProvider = ({ children }) => {
> >
{errorModalContent} {errorModalContent}
</Modal> </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> </ApiServerContext.Provider>
) )
} }