390 lines
11 KiB
JavaScript

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 (
<ObjectProperty
type={item.propertyType}
value={item.value}
objectType={type}
/>
)
} else {
const model = getModelByName(type)
const Icon = model.icon
return (
<Flex gap={'small'} align='center' style={{ width: '100%' }}>
{Icon && <Icon />}
{item?.color && <Badge color={item.color}></Badge>}
<Text ellipsis>{item.name || type.title}</Text>
<IdDisplay id={item._id} longId={false} type={type} />
</Flex>
)
}
},
[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 (
<Space.Compact style={{ width: '100%' }}>
<Input value='Failed to load data.' status='error' disabled />
<Button
icon={<ReloadIcon />}
onClick={() => {
setError(false)
setTreeData([])
}}
danger
/>
</Space.Compact>
)
}
// --- Main TreeSelect UI ---
return (
<TreeSelect
treeDataSimpleMode
treeDefaultExpandAll={true}
loadData={handleTreeLoad}
treeData={treeData}
onChange={handleOnChange}
loading={loading}
value={loading ? 'Loading...' : defaultValue}
showSearch={showSearch}
onSearch={showSearch ? handleSearch : undefined}
treeCheckable={treeCheckable}
showCheckedStrategy={treeCheckable ? SHOW_CHILD : undefined}
{...treeSelectProps}
{...rest}
/>
)
}
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