564 lines
17 KiB
JavaScript

import {
useEffect,
useState,
useContext,
useCallback,
useMemo,
useRef
} from 'react'
import PropTypes from 'prop-types'
import { TreeSelect, Space, Button, Input } from 'antd'
import ReloadIcon from '../../Icons/ReloadIcon'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import ObjectProperty from './ObjectProperty'
import { getModelByName } from '../../../database/ObjectModels'
import merge from 'lodash/merge'
import { getModelProperty } from '../../../database/ObjectModels'
const { SHOW_CHILD } = TreeSelect
// Helper to check if two values are equal (handling objects/ids)
const areValuesEqual = (v1, v2) => {
const id1 = v1 && typeof v1 === 'object' && v1._id ? v1._id : v1
const id2 = v2 && typeof v2 === 'object' && v2._id ? v2._id : v2
return String(id1) === String(id2)
}
const ObjectSelect = ({
type = 'unknown',
showSearch = false,
multiple = false,
treeSelectProps = {},
filter = {},
masterFilter = {},
value,
disabled = false,
...rest
}) => {
const { fetchObjectsByProperty, fetchObject, connected } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
// --- State ---
const [treeData, setTreeData] = useState([])
const [objectPropertiesTree, setObjectPropertiesTree] = useState([])
const [initialized, setInitialized] = useState(false)
const [error, setError] = useState(false)
const properties = useMemo(() => getModelByName(type).group || [], [type])
const [objectList, setObjectList] = useState([])
const [treeSelectValue, setTreeSelectValue] = useState(null)
const [initialLoading, setInitialLoading] = useState(true)
const valueRef = useRef(null)
// Refs to track value changes
const prevValueRef = useRef(value)
const isInternalChangeRef = useRef(false)
// Normalize a value to an identity string so we can detect in-place _id updates
const getValueIdentity = useCallback((val) => {
if (val && typeof val === 'object') {
// Handle arrays
if (Array.isArray(val)) {
const ids = val
.map((item) => {
if (item && typeof item === 'object') {
if (item._id) return String(item._id)
if (
item.value &&
typeof item.value === 'object' &&
item.value._id
)
return String(item.value._id)
}
return null
})
.filter(Boolean)
.sort()
return ids.length > 0 ? ids.join(',') : JSON.stringify(val)
}
// Handle single objects
if (val._id) return String(val._id)
if (val.value && typeof val.value === 'object' && val.value._id)
return String(val.value._id)
}
return JSON.stringify(val)
}, [])
const prevValueIdentityRef = useRef(getValueIdentity(value))
// Utility function to check if object only contains _id
const isMinimalObject = useCallback((obj) => {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return false
}
const keys = Object.keys(obj)
return keys.length === 1 && keys[0] === '_id' && obj._id
}, [])
// Function to fetch full object if only _id is present
const fetchFullObjectIfNeeded = useCallback(
async (obj) => {
if (isMinimalObject(obj)) {
try {
const fullObject = await fetchObject(obj._id, type)
return fullObject
} catch (err) {
console.error('Failed to fetch full object:', err)
return obj // Return original object if fetch fails
}
}
return obj
},
[isMinimalObject, fetchObject, type]
)
const mergeGroups = useCallback((current, incoming) => {
if (!current) return incoming
if (!incoming) return current
if (!Array.isArray(current) || !Array.isArray(incoming)) return incoming
const merged = [...current]
// Helper to generate a unique key for a group node
const getGroupKey = (item) => {
const val = item.value
const valPart =
val && typeof val === 'object' && val._id
? val._id
: JSON.stringify(val)
return `${item.property}:${valPart}`
}
for (const item of incoming) {
if (item.property && item.value !== undefined) {
// It's a group node
const itemKey = getGroupKey(item)
const existingIdx = merged.findIndex(
(x) =>
x.property && x.value !== undefined && getGroupKey(x) === itemKey
)
if (existingIdx > -1) {
merged[existingIdx] = {
...merged[existingIdx],
children: mergeGroups(merged[existingIdx].children, item.children)
}
} else {
merged.push(item)
}
} else {
// It's a leaf object
if (!merged.some((x) => String(x._id) === String(item._id))) {
merged.push(item)
}
}
}
return merged
}, [])
// Fetch the object properties tree from the API
const handleFetchObjectsProperties = useCallback(
async (customFilter = filter) => {
try {
const data = await fetchObjectsByProperty(type, {
properties: properties,
filter: customFilter,
masterFilter
})
if (Array.isArray(data)) {
setObjectPropertiesTree((prev) => mergeGroups(prev, data))
} else {
// Fallback if API returns something unexpected
setObjectPropertiesTree((prev) => merge([], prev, data))
}
setInitialLoading(false)
setError(false)
return data
} catch (err) {
console.error(err)
setError(true)
return null
}
},
[
type,
fetchObjectsByProperty,
properties,
filter,
masterFilter,
mergeGroups
]
)
// Convert the API response to AntD TreeSelect treeData
const buildTreeData = useCallback(
(data, pIdx = 0, parentKeys = [], filterPath = []) => {
if (!data || !Array.isArray(data)) return []
// If we are past the grouping properties, these are leaf objects
if (pIdx >= properties.length) {
return data.map((object) => {
setObjectList((prev) => {
if (prev.some((p) => p._id === object._id)) return prev
return [...prev, object]
})
return {
title: (
<div style={{ paddingTop: 0 }}>
<ObjectProperty
key={object._id}
type='object'
value={object}
objectType={type}
objectData={object}
isEditing={false}
style={{ top: '-0.5px' }}
/>
</div>
),
value: object._id,
key: object._id,
isLeaf: true,
parentKeys,
filterPath
}
})
}
// Group Nodes
return data
.map((group) => {
// Only process if it looks like a group
if (!group.property) return null
const { property, value, children } = group
var valueString = value
if (value && typeof value === 'object' && value._id) {
valueString = value._id
}
if (Array.isArray(valueString)) {
valueString = valueString.join(',')
}
const nodeKey = parentKeys
.concat(property + ':' + valueString)
.join('-')
const newFilterPath = filterPath.concat({
property,
value: valueString
})
const modelProperty = getModelProperty(type, property)
return {
title: <ObjectProperty {...modelProperty} value={value} />,
value: nodeKey,
key: nodeKey,
property,
filterValue: valueString,
parentKeys: parentKeys.concat(valueString),
filterPath: newFilterPath,
selectable: false,
isLeaf: false,
children: buildTreeData(
children,
pIdx + 1,
parentKeys.concat(valueString),
newFilterPath
)
}
})
.filter(Boolean)
},
[properties, type]
)
// --- loadData for async loading on expand ---
const loadData = async (node) => {
// node.property is the property name, node.value is the value key
if (!node.property) return
if (type == 'unknown') return
// Build filter for this node by merging all parent property-value pairs
const customFilter = { ...filter }
if (Array.isArray(node.filterPath)) {
node.filterPath.forEach(({ property, value }) => {
customFilter[property] = value
})
}
// Ensure current node is in filter (should be covered by filterPath, but redundancy is safe)
customFilter[node.property] = node.filterValue
// Fetch children for this node
const data = await handleFetchObjectsProperties(customFilter)
if (!data) return
// Navigate to the specific node's children in the response
let nodeSpecificChildren = data
if (node.filterPath && Array.isArray(node.filterPath)) {
for (const pathItem of node.filterPath) {
if (!Array.isArray(nodeSpecificChildren)) break
const match = nodeSpecificChildren.find(
(g) =>
g.property === pathItem.property &&
areValuesEqual(g.value, pathItem.value)
)
if (match) {
nodeSpecificChildren = match.children
} else {
nodeSpecificChildren = []
break
}
}
}
// Build new tree children only for this specific node
const children = buildTreeData(
nodeSpecificChildren,
properties.indexOf(node.property) + 1,
node.parentKeys || [],
node.filterPath
)
// Update treeData with new children for this node only
setTreeData((prevTreeData) => {
// Helper to recursively update the correct node
const updateNode = (nodes) =>
nodes.map((n) => {
if (n.key === node.key) {
return { ...n, children, isLeaf: children.length === 0 }
} else if (n.children) {
return { ...n, children: updateNode(n.children) }
}
return n
})
return updateNode(prevTreeData)
})
}
const onTreeSelectChange = (value) => {
// Mark this as an internal change
isInternalChangeRef.current = true
// value can be a string (single) or array (multiple)
if (multiple) {
// Multiple selection
let selectedObjects = []
if (Array.isArray(value)) {
selectedObjects = value
.map((id) => objectList.find((obj) => obj._id === id))
.filter(Boolean)
}
setTreeSelectValue(value)
if (rest.onChange) rest.onChange(selectedObjects)
} else {
// Single selection
const selectedObject = objectList.find((obj) => obj._id === value)
setTreeSelectValue(value)
if (rest.onChange) rest.onChange(selectedObject)
}
}
// Update treeData when objectPropertiesTree changes
useEffect(() => {
if (objectPropertiesTree && Object.keys(objectPropertiesTree).length > 0) {
const newTreeData = buildTreeData(objectPropertiesTree)
setTreeData((prev) => {
if (JSON.stringify(prev) !== JSON.stringify(newTreeData)) {
return newTreeData
}
return prev
})
}
}, [objectPropertiesTree, properties, buildTreeData])
useEffect(() => {
const handleValue = async () => {
if (
value &&
typeof value === 'object' &&
value !== null &&
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
type != 'unknown'
) {
// console.log('fetching full object', value)
valueRef.current = value
// Check if value is a minimal object and fetch full object if needed
const fullValue = await fetchFullObjectIfNeeded(value)
// console.log('fullValue', fullValue)
// Build a new filter from value's properties that are in the properties list
const valueFilter = { ...filter }
properties.forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(fullValue, prop)) {
const filterValue = fullValue[prop]
if (
filterValue &&
typeof filterValue === 'object' &&
filterValue._id
) {
valueFilter[prop] = filterValue._id
} else if (filterValue?.name) {
valueFilter[prop] = filterValue.name
} else if (Array.isArray(filterValue)) {
valueFilter[prop] = filterValue.join(',')
} else {
valueFilter[prop] = filterValue
}
}
})
// Fetch with the new filter
handleFetchObjectsProperties(valueFilter)
// console.log('setting treeSelectValue', valueRef.current._id)
setTreeSelectValue(valueRef.current._id)
setInitialized(true)
return
}
if (
!initialized &&
token != null &&
type != 'unknown' &&
type != undefined &&
connected == true
) {
handleFetchObjectsProperties()
setInitialized(true)
}
if (
value == null ||
type == 'unknown' ||
type == undefined ||
connected == false
) {
setTreeSelectValue(null)
setInitialLoading(false)
setInitialized(true)
}
}
handleValue()
}, [
value,
filter,
properties,
handleFetchObjectsProperties,
initialized,
token,
fetchFullObjectIfNeeded,
type,
connected,
getValueIdentity
])
const prevValuesRef = useRef({ type, masterFilter })
useEffect(() => {
// console.log('treeSelectValue', treeSelectValue)
}, [treeSelectValue])
useEffect(() => {
const prevValues = prevValuesRef.current
// Deep comparison for objects, simple comparison for primitives
const hasChanged =
prevValues.type !== type ||
JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
if (hasChanged) {
setObjectPropertiesTree({})
setObjectList([])
setTreeData([])
setInitialized(false)
onTreeSelectChange(null)
setTreeSelectValue(null)
setInitialLoading(true)
setError(false)
prevValuesRef.current = { type, masterFilter }
}
}, [type, masterFilter])
useEffect(() => {
// Check if value has actually changed
const currentValueIdentity = getValueIdentity(value)
const hasValueChanged =
prevValueIdentityRef.current !== currentValueIdentity
if (hasValueChanged) {
const changeSource = isInternalChangeRef.current ? 'internal' : 'external'
if (changeSource == 'external') {
setObjectPropertiesTree({})
setTreeData([])
setInitialized(false)
prevValuesRef.current = { type, masterFilter }
}
// Reset the internal change flag
isInternalChangeRef.current = false
// Update the previous value reference
prevValueRef.current = value
prevValueIdentityRef.current = currentValueIdentity
}
}, [value, getValueIdentity])
const placeholder = useMemo(
() =>
type == 'unknown' || type == undefined
? 'n/a'
: `Select a ${getModelByName(type).label.toLowerCase()}...`,
[type]
)
// --- Error UI ---
if (error) {
return (
<Space.Compact style={{ width: '100%' }}>
<Input value='Failed to load data.' status='error' disabled />
<Button
icon={<ReloadIcon />}
onClick={() => {
setError(false)
setTreeData([])
setInitialized(false)
}}
danger
/>
</Space.Compact>
)
}
if (initialLoading) {
return <TreeSelect disabled loading placeholder='Loading...' />
}
// --- Main TreeSelect UI ---
return (
<TreeSelect
treeDataSimpleMode={false}
treeDefaultExpandAll={true}
treeData={treeData}
showSearch={showSearch}
multiple={multiple}
loadData={loadData}
showCheckedStrategy={SHOW_CHILD}
placeholder={placeholder}
{...treeSelectProps}
{...rest}
value={treeSelectValue}
onChange={onTreeSelectChange}
disabled={disabled || type == 'unknown' || type == undefined}
/>
)
}
ObjectSelect.propTypes = {
properties: PropTypes.arrayOf(PropTypes.string).isRequired,
filter: PropTypes.object,
masterFilter: PropTypes.object,
useFilter: PropTypes.bool,
value: PropTypes.any,
onChange: PropTypes.func,
showSearch: PropTypes.bool,
multiple: PropTypes.bool,
treeSelectProps: PropTypes.object,
type: PropTypes.string.isRequired,
disabled: PropTypes.bool
}
export default ObjectSelect