588 lines
18 KiB
JavaScript
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
|