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 { getTypeMeta } from '../utils/Utils'
import IdDisplay from './IdDisplay'
import CountryDisplay from './CountryDisplay'
import ReloadIcon from '../../Icons/ReloadIcon'
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]
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)
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)
}
}
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 ? (
}
onClick={() => {
setError(false)
setTreeData([])
}}
danger
/>
) : (
)
}
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