import get from 'lodash/get' import set from 'lodash/set' import cloneDeep from 'lodash/cloneDeep' import { mergeFormData } from './Utils' // Patch computed fields onto the complete object. // This mirrors the handling used by ObjectForm. export const applyComputedEntries = (base, entries = []) => { const result = mergeFormData(base || {}) entries.forEach((entry) => { const { namePath, value } = entry || {} if (!Array.isArray(namePath) || value === undefined) return set(result, namePath, value) }) return result } const idsEqual = (left, right) => left != null && right != null && String(left).toLowerCase() === String(right).toLowerCase() export const getReferenceId = (value) => { if (typeof value === 'string') { const trimmed = value.trim() return trimmed || null } if (!value || typeof value !== 'object' || Array.isArray(value)) return null if (typeof value._id === 'string' && value._id.trim()) return value._id if (typeof value._id?._id === 'string' && value._id._id.trim()) { return value._id._id } return null } // A reference is hydrated when it has fields besides _id. // API object ids often arrive as { _id } until they are fetched. const isHydratedObject = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return false return Object.keys(value).some((key) => key !== '_id') } const toNamePath = (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 resolvePropertyObjectType = (property, scopeData, parentData) => { if (typeof property?.objectType !== 'function') { return property?.objectType || null } try { return property.objectType(scopeData || {}, parentData) || null } catch (error) { console.warn( `Error resolving object type for property ${property?.name}:`, error ) return null } } export const collectObjectPropertyRefs = (data, modelDefinition) => { const refs = [] if (!data || typeof data !== 'object') return refs const visit = (properties, parentPath, scopeData, parentData) => { if (!Array.isArray(properties)) return properties.forEach((property) => { if (!property?.name) return const propertyPath = toNamePath(property.name, parentPath) const value = get(data, propertyPath) if (property.type === 'object' || property.type === 'objectList') { const objectType = resolvePropertyObjectType( property, scopeData, parentData ) if (!objectType) return const items = property.type === 'objectList' ? Array.isArray(value) ? value : [] : value == null ? [] : [value] items.forEach((item, index) => { const id = getReferenceId(item) if (!id) return refs.push({ namePath: property.type === 'objectList' ? [...propertyPath, index] : propertyPath, id: String(id), objectType, hydrated: isHydratedObject(item) }) }) return } if ( !Array.isArray(property.properties) || property.properties.length === 0 ) { return } if (property.type === 'objectChildren' && Array.isArray(value)) { value.forEach((child, index) => { visit( property.properties, [...propertyPath, index], child, scopeData ) }) return } if (value && typeof value === 'object' && !Array.isArray(value)) { visit(property.properties, propertyPath, value, scopeData) } }) } visit(modelDefinition?.properties, [], data, null) return refs } export const getRelatedObjectSubscriptionKey = (data, modelDefinition) => { const seen = new Set() collectObjectPropertyRefs(data, modelDefinition).forEach((ref) => { seen.add(`${ref.objectType}:${ref.id.toLowerCase()}`) }) return Array.from(seen).sort().join('|') } const mergeRelatedObjectValue = (existing, id, value) => { const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : { _id: id } return mergeFormData(base, value, { _id: id }) } const withEditingFlag = (prev, next) => { if (prev?._isEditing != null) { next._isEditing = prev._isEditing } return next } export const applyHydratedObjects = ( prev, refs, fetchedByKey, modelDefinition, calculateComputedValues ) => { if (!prev || refs.length === 0 || fetchedByKey.size === 0) return null const draft = cloneDeep(prev) const applied = [] refs.forEach((ref) => { const fullObject = fetchedByKey.get( `${ref.objectType}:${ref.id.toLowerCase()}` ) if (!fullObject) return const current = get(draft, ref.namePath) const currentId = getReferenceId(current) if (!idsEqual(currentId, ref.id) || isHydratedObject(current)) return set( draft, ref.namePath, mergeRelatedObjectValue(current, ref.id, fullObject) ) applied.push(ref) }) if (applied.length === 0) return null const computedEntries = calculateComputedValues(draft, modelDefinition) const next = withEditingFlag( prev, applyComputedEntries(draft, computedEntries) ) return { next, refs: applied, computedEntries } } export const applyRelatedObjectUpdates = ( prev, objectType, id, value, modelDefinition, calculateComputedValues ) => { if (!prev || !objectType || id == null || value == null) return null const normalizedId = String(id).toLowerCase() const refs = collectObjectPropertyRefs(prev, modelDefinition).filter( (ref) => ref.objectType === objectType && ref.id.toLowerCase() === normalizedId ) if (refs.length === 0) return null const draft = cloneDeep(prev) refs.forEach((ref) => { set( draft, ref.namePath, mergeRelatedObjectValue(get(draft, ref.namePath), ref.id, value) ) }) const computedEntries = calculateComputedValues(draft, modelDefinition) const next = withEditingFlag( prev, applyComputedEntries(draft, computedEntries) ) return { next, refs, computedEntries } }