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 ( <> setDeleteModalOpen(false)} loading={deleteLoading} objectType={type} objectName={objectData?.name || objectData?.label || ''} />
{ // 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 })}
) } ) 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