588 lines
18 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 PropTypes from 'prop-types'
import DeleteObjectModal from './DeleteObjectModal'
import merge from 'lodash/merge'
import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels'
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 [lock, setLock] = 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,
lockObject,
unlockObject,
fetchObjectLock,
showError,
connected,
subscribeToObjectUpdates,
subscribeToObjectLock,
flushFile
} = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
// Get the model definition for this object type
const model = getModelByName(type)
// 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 currentUnlockObjectRef = useRef(unlockObject)
const currentHasFilePropertiesRef = useRef(hasFileProperties)
const currentFlushOrphanFilesRef = useRef(flushOrphanFiles)
// Update refs when values change
useEffect(() => {
currentIdRef.current = id
currentTypeRef.current = type
currentIsEditingRef.current = isEditing
currentUnlockObjectRef.current = unlockObject
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
},
[]
)
// 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) {
currentUnlockObjectRef.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 lockEvent = await fetchObjectLock(id, type)
setLock(lockEvent)
onStateChangeRef.current({ lock: lockEvent })
setObjectData({ ...data, _isEditing: isEditingRef.current })
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)
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,
fetchObjectLock,
id,
type,
form,
showMessageError,
showError,
calculateComputedValues,
model
])
// Update event handler
const updateObjectEventHandler = useCallback((value) => {
setObjectData((prev) => merge({}, prev, value))
}, [])
// Update event handler
const updateLockEventHandler = useCallback((value) => {
setLock((prev) => {
onStateChangeRef.current({ lock: { ...prev, ...value } })
return { ...prev, ...value }
})
}, [])
useEffect(() => {
if (connected == true && initialized == false && id && token != null) {
setInitialized(true)
handleFetchObject()
}
}, [id, initialized, handleFetchObject, token, connected])
useEffect(() => {
if (id && connected) {
const objectUpdatesUnsubscribe = subscribeToObjectUpdates(
id,
type,
updateObjectEventHandler
)
const lockUnsubscribe = subscribeToObjectLock(
id,
type,
updateLockEventHandler
)
return () => {
if (objectUpdatesUnsubscribe) objectUpdatesUnsubscribe()
if (lockUnsubscribe) lockUnsubscribe()
}
}
}, [
id,
type,
subscribeToObjectUpdates,
subscribeToObjectLock,
updateObjectEventHandler,
connected,
updateLockEventHandler
])
// Debounce objectData updates sent to parent to limit re-renders
useEffect(() => {
const timeoutId = setTimeout(() => {
onStateChangeRef.current({ objectData })
}, 150)
return () => clearTimeout(timeoutId)
}, [objectData])
const startEditing = () => {
setIsEditing(true)
isEditingRef.current = true
const computedEntries = calculateComputedValues(objectData, model)
const computedValuesObject = buildObjectFromEntries(computedEntries)
setObjectData((prev) => ({
...prev,
...computedValuesObject,
_isEditing: isEditingRef.current
}))
console.log('calculatedEntries', computedValuesObject)
onStateChangeRef.current({
isEditing: true,
objectData: {
...objectData,
...computedValuesObject,
_isEditing: isEditingRef.current
}
})
lockObject(id, type)
}
const cancelEditing = () => {
if (serverObjectData.current) {
// Recalculate computed values when canceling
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: isEditingRef.current })
}
onStateChangeRef.current({ isEditing: isEditingRef.current })
unlockObject(id, type)
}
const handleUpdate = async () => {
try {
const value = await form.validateFields()
setEditLoading(true)
const currentFormData = {
...value,
...objectData
}
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
})
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')
// 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,
lock
}))
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)
}
// Calculate computed values based on current form data
const currentFormData = {
...(serverObjectData.current || {}),
...changedValues
}
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 = merge(
{},
allFormValues,
computedValuesObject
)
mergedFormValues._isEditing = isEditingRef.current
setObjectData((prev) => {
return merge({}, prev, mergedFormValues)
})
}}
>
{children({
loading: fetchLoading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
form,
formValid,
objectData,
setIsEditing,
setObjectData,
editLoading,
lock,
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