530 lines
16 KiB
JavaScript
530 lines
16 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 { 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 <CountryDisplay countryCode={item.value} />
|
|
}
|
|
// For other category nodes, just show the value
|
|
return <Text>{item[propertyOrder[item.propertyId]] || item.value}</Text>
|
|
}
|
|
// For leaf nodes, show icon, name, and id
|
|
const meta = getTypeMeta(type)
|
|
const Icon = meta.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>
|
|
)
|
|
},
|
|
[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 ? (
|
|
<Space.Compact style={{ width: '100%' }}>
|
|
<Input value='Failed to load data.' status='error' disabled />
|
|
|
|
<Button
|
|
icon={<ReloadIcon />}
|
|
onClick={() => {
|
|
setError(false)
|
|
setTreeData([])
|
|
}}
|
|
danger
|
|
/>
|
|
</Space.Compact>
|
|
) : (
|
|
<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
|