Tom Butcher dcbf308988
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor dashboard components to replace LockIndicator with ActivityIndicator
- Updated various components to use ActivityIndicator instead of LockIndicator for better activity tracking.
- Adjusted layout spacing by changing Space component size from 'middle' to 'small' for improved UI consistency.
- Modified object form state to include activities instead of lock status, enhancing the user experience during editing.
2026-07-26 00:51:36 +01:00

770 lines
23 KiB
JavaScript

import {
useState,
useEffect,
useContext,
useCallback,
forwardRef,
useImperativeHandle,
useRef
} from 'react'
import { Form } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { useMessageContext } from '../context/MessageContext'
import DeleteObjectModal from './DeleteObjectModal'
import merge from 'lodash/merge'
import mergeWith from 'lodash/mergeWith'
import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels'
import { useLocation, useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types'
const arrayReplaceCustomizer = (objValue, srcValue) => {
if (Array.isArray(srcValue)) {
return srcValue
}
}
const getUserId = (user) => user?._id || user
const getBeingEditedByOther = (activities, currentUserId) =>
activities.some(
(activity) =>
activity?.mode === 'editing' &&
getUserId(activity.user) &&
getUserId(activity.user) !== currentUserId
)
const isUserEditing = (activities, currentUserId) =>
activities.some(
(activity) =>
activity?.mode === 'editing' && getUserId(activity.user) === currentUserId
)
const applyActivityUpdate = (activities, activity) => {
const userId = getUserId(activity?.user)
if (!userId) {
return activities
}
if (!activity?.mode) {
return activities.filter((item) => getUserId(item.user) !== userId)
}
const existingIndex = activities.findIndex(
(item) => getUserId(item.user) === userId
)
if (existingIndex >= 0) {
const next = [...activities]
next[existingIndex] = { ...activities[existingIndex], ...activity }
return next
}
return [...activities, activity]
}
const buildObjectFromEntries = (entries = []) => {
return entries.reduce((acc, entry) => {
const { namePath, value } = entry || {}
if (!Array.isArray(namePath) || value === undefined) {
return acc
}
set(acc, namePath, value)
return acc
}, {})
}
/**
* ObjectForm is a reusable form component for editing any object type.
* It handles fetching, updating, locking, unlocking, and validation logic.
*
* Props:
* - id: string (required)
* - type: string (required)
* - formItems: array (for ObjectInfo/ObjectProperty items)
* - children: function({
* loading, isEditing, startEditing, cancelEditing, handleUpdate, form, formValid, objectData, setIsEditing, setObjectData
* }) => ReactNode
*/
const ObjectForm = forwardRef(
({ id, type, style, children, onEdit, onStateChange }, ref) => {
const [objectData, setObjectData] = useState(null)
const serverObjectData = useRef(null)
const onStateChangeRef = useRef(onStateChange)
const [fetchLoading, setFetchLoading] = useState(true)
const [editLoading, setEditLoading] = useState(false)
const [activities, setActivities] = useState([])
const [initialized, setInitialized] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const isEditingRef = useRef(false)
const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm()
const formUpdateValues = Form.useWatch([], form)
const { showSuccess, showError: showMessageError } = useMessageContext()
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const [deleteLoading, setDeleteLoading] = useState(false)
const {
fetchObject,
updateObject,
deleteObject,
setObjectActivity,
clearObjectActivity,
fetchObjectActivities,
showError,
connected,
subscribeToObjectUpdates,
subscribeToObjectActivity,
flushFile
} = useContext(ApiServerContext)
const { token, userProfile } = useContext(AuthContext)
const location = useLocation()
const navigate = useNavigate()
const clearAction = useCallback(
(actionParam = 'action') => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete(actionParam)
const newSearch = searchParams.toString()
console.log('newSearch', newSearch)
console.log('location.pathname', location.pathname)
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
},
[location.pathname, location.search, navigate]
)
// Get the model definition for this object type
const model = getModelByName(type)
const notifyActivityState = useCallback(
(nextActivities) => {
onStateChangeRef.current({
activities: nextActivities,
beingEditedByOther: getBeingEditedByOther(
nextActivities,
userProfile?._id
)
})
},
[userProfile?._id]
)
// Check if the model has properties with type 'file' or 'fileList'
const hasFileProperties = useCallback(() => {
if (!model || !model.properties) return false
return model.properties.some(
(property) => property.type === 'file' || property.type === 'fileList'
)
}, [model])
const flushOrphanFiles = useCallback(() => {
if (!model || !model.properties || !objectData) return
model.properties.forEach((property) => {
if (property.type === 'file') {
// Handle single file property
const fileId =
objectData[property.name]?._id || objectData[property.name]
if (fileId) {
flushFile(fileId)
}
} else if (property.type === 'fileList') {
// Handle fileList property
const fileList = objectData[property.name]
if (Array.isArray(fileList)) {
fileList.forEach((file) => {
const fileId = file?._id || file
if (fileId) {
flushFile(fileId)
}
})
}
}
})
}, [model, objectData, flushFile])
// Refs to store current values for cleanup
const currentIdRef = useRef(id)
const currentTypeRef = useRef(type)
const currentIsEditingRef = useRef(isEditing)
const currentSetObjectActivityRef = useRef(setObjectActivity)
const currentClearObjectActivityRef = useRef(clearObjectActivity)
const currentHasFilePropertiesRef = useRef(hasFileProperties)
const currentFlushOrphanFilesRef = useRef(flushOrphanFiles)
// Update refs when values change
useEffect(() => {
currentIdRef.current = id
currentTypeRef.current = type
currentIsEditingRef.current = isEditing
currentSetObjectActivityRef.current = setObjectActivity
currentClearObjectActivityRef.current = clearObjectActivity
currentHasFilePropertiesRef.current = hasFileProperties
currentFlushOrphanFilesRef.current = flushOrphanFiles
})
// Function to calculate computed values from model properties
const calculateComputedValues = useCallback(
(currentData, modelDefinition) => {
if (!modelDefinition || !Array.isArray(modelDefinition.properties)) {
return []
}
// Clone currentData to allow sequential updates
// We use this working copy to calculate subsequent dependent values
const workingData = merge({}, currentData)
const normalizedPath = (name, parentPath = []) => {
if (Array.isArray(name)) {
return [...parentPath, ...name]
}
if (typeof name === 'number') {
return [...parentPath, name]
}
if (typeof name === 'string' && name.length > 0) {
return [...parentPath, ...name.split('.')]
}
return parentPath
}
const getValueAtPath = (dataSource, path) => {
if (!Array.isArray(path) || path.length === 0) {
return dataSource
}
return path.reduce((acc, key) => {
if (acc == null) return acc
return acc[key]
}, dataSource)
}
const computedEntries = []
const processProperty = (property, parentPath = []) => {
if (!property?.name) return
const propertyPath = normalizedPath(property.name, parentPath)
// Determine the scope data for calculation (parent object)
const scopeData =
parentPath.length === 0
? workingData
: getValueAtPath(workingData, parentPath)
if (property.value && typeof property.value === 'function') {
try {
const computedValue = property.value(scopeData || {})
if (computedValue !== undefined) {
computedEntries.push({
namePath: propertyPath,
value: computedValue
})
// Update workingData so subsequent properties can use this value
set(workingData, propertyPath, computedValue)
}
} catch (error) {
console.warn(
`Error calculating value for property ${property.name}:`,
error
)
}
}
if (
Array.isArray(property.properties) &&
property.properties.length > 0
) {
if (property.type === 'objectChildren') {
// Use workingData to get the latest state of children
const childValues = getValueAtPath(workingData, propertyPath)
if (Array.isArray(childValues)) {
childValues.forEach((_, index) => {
property.properties.forEach((childProperty) => {
processProperty(childProperty, [...propertyPath, index])
})
})
}
} else {
property.properties.forEach((childProperty) => {
processProperty(childProperty, propertyPath)
})
}
}
}
modelDefinition.properties.forEach((property) => {
processProperty(property, [])
})
return computedEntries
},
[]
)
const releaseEditingState = useCallback(
(options = {}) => {
const { notifyParent = true } = options
if (serverObjectData.current) {
const computedEntries = calculateComputedValues(
serverObjectData.current,
model
)
const computedValuesObject = buildObjectFromEntries(computedEntries)
const resetFormData = merge(
{},
serverObjectData.current,
computedValuesObject
)
setIsEditing(false)
isEditingRef.current = false
form.setFieldsValue(resetFormData)
setObjectData({ ...resetFormData, _isEditing: false })
} else {
setIsEditing(false)
isEditingRef.current = false
}
if (notifyParent) {
onStateChangeRef.current({ isEditing: false })
}
},
[calculateComputedValues, form, model]
)
const handleEditingConflict = useCallback(
(nextActivities) => {
const currentUserId = userProfile?._id
if (
!isEditingRef.current ||
!getBeingEditedByOther(nextActivities, currentUserId)
) {
return
}
if (isUserEditing(nextActivities, currentUserId)) {
setObjectActivity(id, type, 'editing')
return
}
releaseEditingState()
clearAction()
showMessageError('Another user is already editing this item')
},
[
id,
type,
userProfile?._id,
setObjectActivity,
releaseEditingState,
clearAction,
showMessageError
]
)
// Validate form on change (debounced to avoid heavy work on every keystroke)
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentFormValues = form.getFieldsValue()
const mergedObjectData = {
...serverObjectData.current,
...currentFormValues,
_isEditing: isEditingRef.current
}
form
.validateFields({ validateOnly: true })
.then(() => {
setFormValid(true)
onStateChangeRef.current({
formValid: true,
objectData: mergedObjectData
})
})
.catch(() => {
setFormValid(false)
onStateChangeRef.current({
formValid: false,
objectData: mergedObjectData
})
})
}, 150)
return () => clearTimeout(timeoutId)
}, [form, formUpdateValues])
// Cleanup on unmount
useEffect(() => {
return () => {
if (currentIdRef.current) {
currentClearObjectActivityRef.current(
currentIdRef.current,
currentTypeRef.current
)
}
// Call flushOrphanFiles if component was editing and model has file properties
if (
currentIsEditingRef.current &&
currentHasFilePropertiesRef.current()
) {
currentFlushOrphanFilesRef.current()
}
}
}, []) // Empty dependency array - only run on mount/unmount
const handleFetchObject = useCallback(async () => {
try {
setFetchLoading(true)
onStateChangeRef.current({ loading: true })
const data = await fetchObject(id, type)
const initialActivities = await fetchObjectActivities(id, type)
setActivities(initialActivities)
notifyActivityState(initialActivities)
if (
isEditingRef.current &&
getBeingEditedByOther(initialActivities, userProfile?._id)
) {
releaseEditingState()
}
serverObjectData.current = data
// Calculate and set computed values on initial load
const computedEntries = calculateComputedValues(data, model)
const computedValuesObject = buildObjectFromEntries(computedEntries)
const initialFormData = merge({}, data, computedValuesObject)
setObjectData({ ...initialFormData, _isEditing: isEditingRef.current })
form.setFieldsValue(initialFormData)
setFetchLoading(false)
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
)
}
}, [
fetchObject,
fetchObjectActivities,
notifyActivityState,
id,
type,
form,
showMessageError,
showError,
calculateComputedValues,
model,
userProfile?._id,
releaseEditingState
])
// Update event handler
const updateObjectEventHandler = useCallback((value) => {
setObjectData((prev) =>
mergeWith({}, prev, value, arrayReplaceCustomizer)
)
}, [])
// Update event handler
const updateActivityEventHandler = useCallback(
(activity, activities) => {
setActivities((prev) => {
const next = Array.isArray(activities)
? activities
: applyActivityUpdate(prev, activity)
notifyActivityState(next)
handleEditingConflict(next)
return next
})
},
[notifyActivityState, handleEditingConflict]
)
useEffect(() => {
if (connected == true && initialized == false && id && token != null) {
setInitialized(true)
handleFetchObject()
}
}, [id, initialized, handleFetchObject, token, connected])
useEffect(() => {
if (id && connected) {
currentSetObjectActivityRef.current(
id,
type,
isEditingRef.current == true ? 'editing' : 'viewing'
)
const objectUpdatesUnsubscribe = subscribeToObjectUpdates(
id,
type,
updateObjectEventHandler
)
const activityUnsubscribe = subscribeToObjectActivity(
id,
type,
updateActivityEventHandler
)
return () => {
if (objectUpdatesUnsubscribe) objectUpdatesUnsubscribe()
if (activityUnsubscribe) activityUnsubscribe()
currentClearObjectActivityRef.current(id, type)
}
}
}, [
id,
type,
connected,
subscribeToObjectUpdates,
subscribeToObjectActivity,
updateObjectEventHandler,
updateActivityEventHandler
])
// Debounce objectData updates sent to parent to limit re-renders
useEffect(() => {
const timeoutId = setTimeout(() => {
onStateChangeRef.current({ objectData })
}, 150)
return () => clearTimeout(timeoutId)
}, [objectData])
const startEditing = async () => {
try {
const latestActivities = await fetchObjectActivities(id, type)
setActivities(latestActivities)
notifyActivityState(latestActivities)
if (getBeingEditedByOther(latestActivities, userProfile?._id)) {
clearAction()
showMessageError('Another user is already editing this item')
return
}
const activityResult = await setObjectActivity(id, type, 'editing')
if (activityResult?.success === false) {
clearAction()
showMessageError('Another user is already editing this item')
return
}
isEditingRef.current = true
setIsEditing(true)
const computedEntries = calculateComputedValues(objectData, model)
const computedValuesObject = buildObjectFromEntries(computedEntries)
setObjectData((prev) => ({
...prev,
...computedValuesObject,
_isEditing: true
}))
onStateChangeRef.current({
isEditing: true,
objectData: {
...objectData,
...computedValuesObject,
_isEditing: true
}
})
} catch (err) {
console.error(err)
showMessageError('Failed to start editing')
}
}
const cancelEditing = () => {
releaseEditingState()
setObjectActivity(id, type, 'viewing')
}
const handleUpdate = async () => {
try {
const value = await form.validateFields()
setEditLoading(true)
const currentFormData = {
...objectData,
...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
}
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 })
}
}
const handleDelete = () => {
setDeleteModalOpen(true)
}
const confirmDelete = async () => {
setDeleteLoading(true)
try {
await deleteObject(id, type)
setDeleteModalOpen(false)
showSuccess('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
)
} finally {
setDeleteLoading(false)
}
}
useImperativeHandle(ref, () => ({
startEditing,
cancelEditing,
handleUpdate,
handleDelete,
confirmDelete,
handleFetchObject,
editLoading,
fetchLoading,
isEditing,
objectData,
activities
}))
return (
<>
<DeleteObjectModal
open={deleteModalOpen}
onOk={confirmDelete}
onCancel={() => setDeleteModalOpen(false)}
loading={deleteLoading}
objectType={type}
objectName={objectData?.name || objectData?.label || ''}
/>
<Form
form={form}
layout='vertical'
style={style}
onValuesChange={(changedValues, allFormValues) => {
// Use the full form snapshot (allFormValues) so list fields (Form.List)
// come through as complete arrays instead of sparse arrays like
// [null, null, { quantity: 5 }].
if (onEdit != undefined) {
onEdit(allFormValues)
}
// Recompute derived fields from the full current form snapshot so
// toggles like overridePrice/overrideCost are preserved while typing.
const currentFormData = mergeWith(
{},
serverObjectData.current || {},
objectData || {},
allFormValues,
arrayReplaceCustomizer
)
const computedEntries = calculateComputedValues(
currentFormData,
model
)
if (Array.isArray(computedEntries) && computedEntries.length > 0) {
computedEntries.forEach(({ namePath, value }) => {
if (!Array.isArray(namePath) || value === undefined) return
const currentValue = form.getFieldValue(namePath)
if (currentValue !== value) {
if (typeof form.setFieldValue === 'function') {
form.setFieldValue(namePath, value)
} else {
const fallbackPayload = buildObjectFromEntries([
{ namePath, value }
])
form.setFieldsValue(fallbackPayload)
}
}
})
}
const computedValuesObject = buildObjectFromEntries(computedEntries)
const mergedFormValues = mergeWith(
{},
allFormValues,
arrayReplaceCustomizer
)
merge(mergedFormValues, computedValuesObject)
mergedFormValues._isEditing = isEditingRef.current
setObjectData((prev) => {
return mergeWith(
{},
prev,
mergedFormValues,
arrayReplaceCustomizer
)
})
}}
>
{children({
loading: fetchLoading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
form,
formValid,
objectData,
setIsEditing,
setObjectData,
editLoading,
activities,
handleFetchObject,
handleDelete
})}
</Form>
</>
)
}
)
ObjectForm.displayName = 'ObjectForm'
ObjectForm.propTypes = {
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
children: PropTypes.func.isRequired,
style: PropTypes.object,
onEdit: PropTypes.func,
onStateChange: PropTypes.func
}
export default ObjectForm