import React, { useEffect, useState, useCallback } from 'react' import PropTypes from 'prop-types' import { TreeSelect, Typography, Flex, Badge, Space, Button, Input } from 'antd' import axios from 'axios' import { getModelByName } from '../../../database/ObjectModels' import IdDisplay from './IdDisplay' import ReloadIcon from '../../Icons/ReloadIcon' import ObjectProperty from './ObjectProperty' const { Text } = Typography const { SHOW_CHILD } = TreeSelect // --- Utility: Resolve nested property path (e.g., 'filament.diameter') --- function resolvePropertyPath(obj, path) { if (!obj || !path) return { value: undefined, finalProperty: undefined } const props = path.split('.') let value = obj for (const prop of props) { if (value && typeof value === 'object') { value = value[prop] } else { return { value: undefined, finalProperty: prop } } } return { value, finalProperty: props[props.length - 1] } } // --- Utility: Build filter object for a node based on propertyOrder --- function buildFilterForNode(node, treeData, propertyOrder) { let filterObj = {} let currentId = node.id while (currentId !== 0) { const currentNode = treeData.find((d) => d.id === currentId) if (!currentNode) break filterObj[propertyOrder[currentNode.propertyId]] = currentNode.value currentId = currentNode.pId } return filterObj } /** * ObjectSelect - a generic, reusable async TreeSelect for hierarchical object selection. * * Props: * - endpoint: API endpoint to fetch data from (required) * - propertyOrder: array of property names for category levels (required) * - filter: object for filtering (optional) * - useFilter: bool (optional) * - value: selected value (optional) - can be an object with _id, array of objects, or simple value/array * - onChange: function (optional) * - showSearch: bool (optional, default false) * - treeCheckable: bool (optional, default false) - enables multi-select mode with checkboxes * - treeSelectProps: any other TreeSelect props (optional) */ const ObjectSelect = ({ endpoint, propertyOrder, filter = {}, useFilter = false, value, onChange, showSearch = false, treeCheckable = false, treeSelectProps = {}, type = 'unknown', ...rest }) => { // --- State --- const [treeData, setTreeData] = useState([]) const [loading, setLoading] = useState(false) const [defaultValue, setDefaultValue] = useState(treeCheckable ? [] : value) const [searchValue, setSearchValue] = useState('') const [error, setError] = useState(false) // --- API: Fetch data for a property level or leaf --- const fetchData = useCallback( async (property, filter, search) => { setLoading(true) setError(false) try { const params = { ...filter, property } if (search) params.search = search const response = await axios.get(endpoint, { params, withCredentials: true }) setLoading(false) return response.data } catch (err) { setLoading(false) setError(true) return [] } }, [endpoint] ) // --- API: Fetch a single object by ID --- const fetchObjectById = useCallback( async (objectId) => { setLoading(true) setError(false) try { const response = await axios.get(`${endpoint}/${objectId}`, { withCredentials: true }) setLoading(false) return response.data } catch (err) { setLoading(false) setError(true) return null } }, [endpoint] ) // --- Render node title --- const renderTitle = useCallback( (item) => { if (item.propertyType) { return ( ) } else { const model = getModelByName(type) const Icon = model.icon return ( {Icon && } {item?.color && } {item.name || type.title} ) } }, [type] ) // --- Build tree nodes for a property level --- const buildCategoryNodes = useCallback( (data, propertyName, propertyId, parentId) => { return data.map((item) => { let resolved = resolvePropertyPath(item, propertyName) let value = resolved.value let propertyType = resolved.finalProperty return { id: value, pId: parentId, value: value, key: value, propertyId: propertyId, title: renderTitle({ ...item, value, propertyType }), isLeaf: false, selectable: false, raw: item } }) }, [renderTitle] ) // --- Build tree nodes for leaf level --- const buildLeafNodes = useCallback( (data, parentId) => { return data.map((item) => { const value = item._id || item.id || item.value return { id: value, pId: parentId, value: value, key: value, title: renderTitle(item), isLeaf: true, raw: item } }) }, [renderTitle] ) // --- Tree loader: load children for a node or root --- const handleTreeLoad = useCallback( async (node) => { if (!propertyOrder.length) return if (node) { // Not at leaf level yet if (node.propertyId !== propertyOrder.length - 1) { const nextPropertyId = node.propertyId + 1 const propertyName = propertyOrder[nextPropertyId] const filterObj = buildFilterForNode(node, treeData, propertyOrder) const data = await fetchData(propertyName, filterObj, searchValue) setTreeData((prev) => [ ...prev, ...buildCategoryNodes(data, propertyName, nextPropertyId, node.id) ]) } else { // At leaf level const filterObj = buildFilterForNode(node, treeData, propertyOrder) const data = await fetchData(null, filterObj, searchValue) setTreeData((prev) => [...prev, ...buildLeafNodes(data, node.id)]) } } else { // Root load const propertyName = propertyOrder[0] const data = await fetchData(propertyName, {}, searchValue) setTreeData(buildCategoryNodes(data, propertyName, 0, 0)) } }, [ propertyOrder, treeData, fetchData, buildCategoryNodes, buildLeafNodes, searchValue ] ) // --- OnChange handler --- const handleOnChange = (val, selectedOptions) => { if (onChange) { if (treeCheckable) { // Multi-select let selectedObjects = [] if (Array.isArray(val)) { selectedObjects = val.map((selectedValue) => { const node = treeData.find((n) => n.value === selectedValue) return node ? node.raw : selectedValue }) } onChange(selectedObjects, selectedOptions) } else { // Single select const node = treeData.find((n) => n.value === val) onChange(node ? node.raw : val, selectedOptions) } } setDefaultValue(val) } // --- Search handler --- const handleSearch = (val) => { setSearchValue(val) setTreeData([]) } // --- Sync defaultValue and load tree path for object values --- useEffect(() => { if (treeCheckable) { if (Array.isArray(value)) { const valueIds = value.map((v) => v._id || v.id || v) setDefaultValue(valueIds) value.forEach((item) => { if (item && typeof item === 'object' && item._id) { const existingNode = treeData.find( (node) => node.value === item._id ) if (!existingNode) { fetchObjectById(item._id).then((object) => { if (object) { // For multi-select, just add the leaf node setTreeData((prev) => [ ...prev, ...buildLeafNodes( [object], object[propertyOrder[propertyOrder.length - 2]] || 0 ) ]) } }) } } }) } else { setDefaultValue([]) } } else { if (value?._id) { setDefaultValue(value._id) const existingNode = treeData.find((node) => node.value === value._id) if (!existingNode) { fetchObjectById(value._id).then((object) => { if (object) { setTreeData((prev) => [ ...prev, ...buildLeafNodes( [object], object[propertyOrder[propertyOrder.length - 2]] || 0 ) ]) } }) } } } }, [ value, treeData, fetchObjectById, buildLeafNodes, propertyOrder, treeCheckable ]) // --- Initial load --- useEffect(() => { if (treeData.length === 0 && !error && !loading) { if (!treeCheckable && value && typeof value === 'object' && value._id) { return } if (useFilter || searchValue) { // Flat filter mode fetchData(null, filter, searchValue).then((data) => { setTreeData(buildLeafNodes(data, 0)) }) } else { handleTreeLoad(null) } } }, [ treeData, useFilter, filter, searchValue, buildLeafNodes, fetchData, handleTreeLoad, error, loading, value, treeCheckable ]) // --- Error UI --- if (error) { return (