import React, { useEffect, useState, useCallback } from 'react' import PropTypes from 'prop-types' import { TreeSelect, Typography, Flex, Badge } from 'antd' import axios from 'axios' import { getTypeMeta } from '../utils/Utils' import IdText from './IdText' import CountryDisplay from './CountryDisplay' const { Text } = Typography const { SHOW_CHILD } = TreeSelect /** * 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 }) => { const [treeData, setTreeData] = useState([]) const [loading, setLoading] = useState(false) const [defaultValue, setDefaultValue] = useState(treeCheckable ? [] : value) const [searchValue, setSearchValue] = useState('') const [error, setError] = useState(false) // Helper to get filter object for a node const getFilter = useCallback( (node) => { 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 }, [treeData, propertyOrder] ) // Fetch data from API 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) // Optionally handle error return [] } }, [endpoint] ) // Fetch 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) console.error('Failed to fetch object by ID:', err) return null } }, [endpoint] ) // Helper to render the title for a node const renderTitle = useCallback( (item, isLeaf) => { if (!isLeaf) { // For category nodes, check if it's a country property const currentProperty = propertyOrder[item.propertyId] if (currentProperty === 'country' && item.value) { return } // For other category nodes, just show the value return {item[propertyOrder[item.propertyId]] || item.value} } // For leaf nodes, show icon, name, and id const meta = getTypeMeta(type) const Icon = meta.icon return ( {Icon && } {item?.color && } {item.name || type.title} ) }, [propertyOrder, type] ) // Build tree path for a default object const buildTreePathForObject = useCallback( async (object) => { if (!object || !propertyOrder || propertyOrder.length === 0) return const newNodes = [] let currentPId = 0 // Build category nodes for each property level and load all available options for (let i = 0; i < propertyOrder.length; i++) { const propertyName = propertyOrder[i] console.log('propname', propertyName) let propertyValue // Handle nested property access (e.g., 'filament.diameter') if (propertyName.includes('.')) { const propertyPath = propertyName.split('.') let currentValue = object for (const prop of propertyPath) { if (currentValue && typeof currentValue === 'object') { currentValue = currentValue[prop] } else { currentValue = undefined break } } propertyValue = currentValue } else { propertyValue = object[propertyName] } // Build filter for this level let filterObj = {} for (let j = 0; j < i; j++) { const prevPropertyName = propertyOrder[j] let prevPropertyValue if (prevPropertyName.includes('.')) { const propertyPath = prevPropertyName.split('.') let currentValue = object for (const prop of propertyPath) { if (currentValue && typeof currentValue === 'object') { currentValue = currentValue[prop] } else { currentValue = undefined break } } prevPropertyValue = currentValue } else { prevPropertyValue = object[prevPropertyName] } if (prevPropertyValue !== undefined && prevPropertyValue !== null) { filterObj[prevPropertyName] = prevPropertyValue } } // Fetch all available options for this property level const data = await fetchData(propertyName, filterObj, '') // Create nodes for all available options at this level const levelNodes = data.map((item) => { let value if (typeof item === 'object' && item !== null) { if (propertyName.includes('.')) { const propertyPath = propertyName.split('.') let currentValue = item for (const prop of propertyPath) { if (currentValue && typeof currentValue === 'object') { currentValue = currentValue[prop] } else { currentValue = undefined break } } value = currentValue } else { value = item[propertyName] } } else { value = item } return { id: value, pId: currentPId, value: value, key: value, propertyId: i, title: renderTitle({ ...item, value }, false), isLeaf: false, selectable: false, raw: item } }) newNodes.push(...levelNodes) // Update currentPId to the object's property value for the next level if (propertyValue !== undefined && propertyValue !== null) { currentPId = propertyValue } } // Load all leaf nodes at the final level let finalFilterObj = {} for (let j = 0; j < propertyOrder.length - 1; j++) { const prevPropertyName = propertyOrder[j] let prevPropertyValue if (prevPropertyName.includes('.')) { const propertyPath = prevPropertyName.split('.') let currentValue = object for (const prop of propertyPath) { if (currentValue && typeof currentValue === 'object') { currentValue = currentValue[prop] } else { currentValue = undefined break } } prevPropertyValue = currentValue } else { prevPropertyValue = object[prevPropertyName] } if (prevPropertyValue !== undefined && prevPropertyValue !== null) { finalFilterObj[prevPropertyName] = prevPropertyValue } } const leafData = await fetchData(null, finalFilterObj, '') const leafNodes = leafData.map((item) => ({ id: item._id || item.id || item.value, pId: currentPId, value: item._id || item.id || item.value, key: item._id || item.id || item.value, title: renderTitle(item, true), isLeaf: true, raw: item })) newNodes.push(...leafNodes) setTreeData(newNodes) setDefaultValue(object._id || object.id) }, [propertyOrder, renderTitle, fetchData] ) // Generate leaf nodes const generateLeafNodes = useCallback( async (node = null, filterArg = null, search = '') => { if (!node) return const actualFilter = filterArg === null ? getFilter(node) : filterArg const data = await fetchData(null, actualFilter, search) const newNodes = data.map((item) => { const isLeaf = true return { id: item._id || item.id || item.value, pId: node.id, value: item._id || item.id || item.value, key: item._id || item.id || item.value, title: renderTitle(item, isLeaf), isLeaf: true, raw: item } }) setTreeData((prev) => [...prev, ...newNodes]) }, [fetchData, getFilter, renderTitle] ) // Generate category nodes const generateCategoryNodes = useCallback( async (node = null, search = '') => { let filterObj = {} let propertyId = 0 if (!node) { node = { id: 0 } } else { filterObj = getFilter(node) propertyId = node.propertyId + 1 } const propertyName = propertyOrder[propertyId] const data = await fetchData(propertyName, filterObj, search) const newNodes = data.map((item) => { const isLeaf = false // Handle both cases: when item is a simple value or when it's an object let value if (typeof item === 'object' && item !== null) { // Handle nested property access (e.g., 'filament.diameter') if (propertyName.includes('.')) { const propertyPath = propertyName.split('.') let currentValue = item for (const prop of propertyPath) { if (currentValue && typeof currentValue === 'object') { currentValue = currentValue[prop] } else { currentValue = undefined break } } value = currentValue } else { // If item is an object, try to get the property value value = item[propertyName] } } else { // If item is a simple value (string, number, etc.), use it directly value = item } const title = renderTitle({ ...item, value }, isLeaf) console.log('propname', propertyName) console.log('value', value) console.log(item) return { id: value, pId: node.id, value: value, key: value, propertyId: propertyId, title: title, isLeaf: false, selectable: false, raw: item } }) setTreeData((prev) => [...prev, ...newNodes]) }, [fetchData, getFilter, propertyOrder, renderTitle] ) // Tree loader const handleTreeLoad = useCallback( async (node) => { if (node) { if (node.propertyId !== propertyOrder.length - 1) { await generateCategoryNodes(node, searchValue) } else { await generateLeafNodes(node, null, searchValue) } } else { await generateCategoryNodes(null, searchValue) } }, [propertyOrder, generateCategoryNodes, generateLeafNodes, searchValue] ) // OnChange handler const handleOnChange = (val, selectedOptions) => { if (onChange) { if (treeCheckable) { // Handle multiple selections with checkboxes const selectedObjects = [] if (Array.isArray(val)) { val.forEach((selectedValue) => { const node = treeData.find((n) => n.value === selectedValue) if (node) { selectedObjects.push(node.raw) } else { selectedObjects.push(selectedValue) } }) } onChange(selectedObjects, selectedOptions) } else { // Handle single selection const node = treeData.find((n) => n.value === val) onChange(node ? node.raw : val, selectedOptions) } } console.log('val', val) setDefaultValue(val) } // Search handler const handleSearch = (val) => { setSearchValue(val) setTreeData([]) } // Keep defaultValue in sync and handle object values useEffect(() => { if (treeCheckable) { // Handle array of values for multi-select if (Array.isArray(value)) { const valueIds = value.map((v) => v._id || v.id || v) setDefaultValue(valueIds) // Load tree paths for any objects that aren't already loaded 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) { buildTreePathForObject(object) } }) } } }) } else { setDefaultValue([]) } } else { // Handle single value if (value?._id) { setDefaultValue(value._id) } // Check if value is an object with _id (default object case) if (value && typeof value === 'object' && value._id) { // If we already have this object loaded, don't fetch again const existingNode = treeData.find((node) => node.value === value._id) if (!existingNode) { fetchObjectById(value._id).then((object) => { if (object) { buildTreePathForObject(object) } }) } } } }, [value, treeData, fetchObjectById, buildTreePathForObject, treeCheckable]) // Initial load useEffect(() => { if (treeData.length === 0 && !error && !loading) { // If we have a default object value, don't load the regular tree if (!treeCheckable && value && typeof value === 'object' && value._id) { return } if (useFilter || searchValue) { generateLeafNodes({ id: 0 }, filter, searchValue) } else { handleTreeLoad(null) } } }, [ treeData, useFilter, filter, searchValue, generateLeafNodes, handleTreeLoad, error, loading, value, treeCheckable ]) return error ? (
Failed to load data.{' '}
) : ( ) } ObjectSelect.propTypes = { endpoint: PropTypes.string.isRequired, propertyOrder: PropTypes.arrayOf(PropTypes.string).isRequired, filter: PropTypes.object, useFilter: PropTypes.bool, value: PropTypes.any, onChange: PropTypes.func, showSearch: PropTypes.bool, treeCheckable: PropTypes.bool, treeSelectProps: PropTypes.object, type: PropTypes.string.isRequired } export default ObjectSelect