390 lines
11 KiB
JavaScript
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
|