Enhance ObjectTable and ScrollBox components with new filtering capabilities
- Introduced a new SimplePropertyFilter component for advanced filtering options in the ObjectTable. - Updated ObjectTable to utilize ColumnFilterDropdown for improved filter management and user experience. - Enhanced ScrollBox to support additional padding options for better layout control. - Refactored related components to integrate new filtering logic and maintain consistent styling across the dashboard.
This commit is contained in:
parent
ed4f1c7505
commit
0b110cdfff
@ -513,6 +513,24 @@ body {
|
|||||||
height: 8px !important;
|
height: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scrollbox-inner .simplebar-track.simplebar-vertical {
|
||||||
|
right: var(--scrollbox-vertical-right-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbox-inner .simplebar-track.simplebar-horizontal {
|
||||||
|
bottom: var(--scrollbox-horizontal-bottom-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbox-inner-small-padding .simplebar-track.simplebar-vertical {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbox-inner-small-padding .simplebar-track.simplebar-horizontal {
|
||||||
|
bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.simplebar-scrollbar:before {
|
.simplebar-scrollbar:before {
|
||||||
background: #78787854 !important;
|
background: #78787854 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,8 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Form,
|
Form,
|
||||||
Splitter
|
Splitter,
|
||||||
|
Card
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
@ -49,6 +50,7 @@ import { useActions } from '../context/ActionsContext'
|
|||||||
import ActionsIcon from '../../Icons/ActionsIcon'
|
import ActionsIcon from '../../Icons/ActionsIcon'
|
||||||
import FilterIcon from '../../Icons/FilterIcon'
|
import FilterIcon from '../../Icons/FilterIcon'
|
||||||
import ScrollBox from './ScrollBox'
|
import ScrollBox from './ScrollBox'
|
||||||
|
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||||
import {
|
import {
|
||||||
getActiveFilterValues,
|
getActiveFilterValues,
|
||||||
useTableStatePersistence
|
useTableStatePersistence
|
||||||
@ -68,6 +70,120 @@ const getCardColSpan = (containerWidth) => {
|
|||||||
return 24
|
return 24
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toFilterExpression = (values) => {
|
||||||
|
if (!values?.length) return undefined
|
||||||
|
const parts = values.map((value) => {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return String(value._id ?? value.type ?? JSON.stringify(value))
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
})
|
||||||
|
return parts.length === 1 ? parts[0] : parts.join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromFilterExpression = (expr) => {
|
||||||
|
if (expr === undefined || expr === null || expr === '') return null
|
||||||
|
if (typeof expr === 'string' && expr.includes('|')) {
|
||||||
|
return expr.split('|').filter((part) => part !== '')
|
||||||
|
}
|
||||||
|
return [expr]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ColumnFilterDropdown = ({
|
||||||
|
setSelectedKeys,
|
||||||
|
selectedKeys,
|
||||||
|
confirm,
|
||||||
|
clearFilters,
|
||||||
|
visible,
|
||||||
|
propertyName,
|
||||||
|
propertyLabel,
|
||||||
|
modelType
|
||||||
|
}) => {
|
||||||
|
const [expression, setExpression] = useState('')
|
||||||
|
const [draft, setDraft] = useState(selectedKeys || [])
|
||||||
|
const selectedKeysRef = useRef(selectedKeys)
|
||||||
|
selectedKeysRef.current = selectedKeys
|
||||||
|
|
||||||
|
// Re-sync from the applied sidebar/column filter each time the dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
const keys = selectedKeysRef.current || []
|
||||||
|
setDraft(keys)
|
||||||
|
setExpression(toFilterExpression(keys) ?? '')
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
const handleDraftChange = (next) => {
|
||||||
|
setDraft(next)
|
||||||
|
setExpression(toFilterExpression(next) ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExpressionChange = (e) => {
|
||||||
|
const text = e.target.value
|
||||||
|
setExpression(text)
|
||||||
|
setDraft(fromFilterExpression(text) || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFilter = () => {
|
||||||
|
const trimmed = expression.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
clearFilters()
|
||||||
|
} else if (draft?.length) {
|
||||||
|
setSelectedKeys(draft)
|
||||||
|
} else {
|
||||||
|
setSelectedKeys([trimmed])
|
||||||
|
}
|
||||||
|
confirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilter = () => {
|
||||||
|
setExpression('')
|
||||||
|
setDraft([])
|
||||||
|
clearFilters()
|
||||||
|
confirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8 }}>
|
||||||
|
<Flex vertical gap='small'>
|
||||||
|
<Space.Compact>
|
||||||
|
<Input
|
||||||
|
placeholder={'Filter ' + propertyLabel}
|
||||||
|
value={expression}
|
||||||
|
onChange={handleExpressionChange}
|
||||||
|
onPressEnter={applyFilter}
|
||||||
|
style={{ width: 200, display: 'block' }}
|
||||||
|
/>
|
||||||
|
<Button onClick={resetFilter} icon={<XMarkIcon />} />
|
||||||
|
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
||||||
|
</Space.Compact>
|
||||||
|
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
|
||||||
|
<ScrollBox inner={true} smallPadding={true}>
|
||||||
|
<div style={{ padding: '18px 20px', minWidth: 0 }}>
|
||||||
|
<SimplePropertyFilter
|
||||||
|
modelType={modelType}
|
||||||
|
propertyName={propertyName}
|
||||||
|
value={draft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollBox>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnFilterDropdown.propTypes = {
|
||||||
|
setSelectedKeys: PropTypes.func,
|
||||||
|
selectedKeys: PropTypes.array,
|
||||||
|
confirm: PropTypes.func,
|
||||||
|
clearFilters: PropTypes.func,
|
||||||
|
visible: PropTypes.bool,
|
||||||
|
propertyName: PropTypes.string,
|
||||||
|
propertyLabel: PropTypes.string,
|
||||||
|
modelType: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
const RowForm = ({ record, isEditing, onRegister, children }) => {
|
const RowForm = ({ record, isEditing, onRegister, children }) => {
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -973,36 +1089,21 @@ const ObjectTable = forwardRef(
|
|||||||
selectedKeys,
|
selectedKeys,
|
||||||
confirm,
|
confirm,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
propertyName
|
visible,
|
||||||
}) => {
|
propertyName,
|
||||||
return (
|
propertyLabel
|
||||||
<div style={{ padding: 8 }}>
|
}) => (
|
||||||
<Space.Compact>
|
<ColumnFilterDropdown
|
||||||
<Input
|
setSelectedKeys={setSelectedKeys}
|
||||||
placeholder={'Search ' + propertyName}
|
selectedKeys={selectedKeys}
|
||||||
value={selectedKeys[0]}
|
confirm={confirm}
|
||||||
onChange={(e) =>
|
clearFilters={clearFilters}
|
||||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
visible={visible}
|
||||||
}
|
propertyName={propertyName}
|
||||||
onPressEnter={() => confirm()}
|
propertyLabel={propertyLabel}
|
||||||
style={{ width: 200, display: 'block' }}
|
modelType={type}
|
||||||
/>
|
/>
|
||||||
<Button
|
)
|
||||||
onClick={() => {
|
|
||||||
clearFilters()
|
|
||||||
confirm()
|
|
||||||
}}
|
|
||||||
icon={<XMarkIcon />}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type='primary'
|
|
||||||
onClick={() => confirm()}
|
|
||||||
icon={<CheckIcon />}
|
|
||||||
/>
|
|
||||||
</Space.Compact>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
if (isInternalSortUpdateRef.current) return
|
if (isInternalSortUpdateRef.current) return
|
||||||
@ -1010,8 +1111,9 @@ const ObjectTable = forwardRef(
|
|||||||
const next = { ...sidebarFilter }
|
const next = { ...sidebarFilter }
|
||||||
|
|
||||||
Object.entries(filters).forEach(([key, value]) => {
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
if (value && value.length > 0) {
|
const expression = toFilterExpression(value)
|
||||||
next[key] = value[0]
|
if (expression !== undefined) {
|
||||||
|
next[key] = expression
|
||||||
} else {
|
} else {
|
||||||
delete next[key]
|
delete next[key]
|
||||||
}
|
}
|
||||||
@ -1182,18 +1284,21 @@ const ObjectTable = forwardRef(
|
|||||||
setSelectedKeys,
|
setSelectedKeys,
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
confirm,
|
confirm,
|
||||||
clearFilters
|
clearFilters,
|
||||||
|
visible
|
||||||
}) =>
|
}) =>
|
||||||
getFilterDropdown({
|
getFilterDropdown({
|
||||||
setSelectedKeys,
|
setSelectedKeys,
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
confirm,
|
confirm,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
propertyName: prop.label
|
visible,
|
||||||
|
propertyName: prop.name,
|
||||||
|
propertyLabel: prop.label
|
||||||
})
|
})
|
||||||
const sidebarVal = sidebarFilter[prop.name]
|
columnConfig.filteredValue = fromFilterExpression(
|
||||||
columnConfig.filteredValue =
|
sidebarFilter[prop.name]
|
||||||
sidebarVal !== undefined && sidebarVal !== '' ? [sidebarVal] : null
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
columnsWithSkeleton.push(columnConfig)
|
columnsWithSkeleton.push(columnConfig)
|
||||||
|
|||||||
@ -7,6 +7,8 @@ const ScrollBox = ({
|
|||||||
style,
|
style,
|
||||||
horizontalBottomPadding = 16,
|
horizontalBottomPadding = 16,
|
||||||
verticalRightPadding = 16,
|
verticalRightPadding = 16,
|
||||||
|
inner = false,
|
||||||
|
smallPadding = false,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@ -17,6 +19,7 @@ const ScrollBox = ({
|
|||||||
'--scrollbox-vertical-right-padding': `${verticalRightPadding}px`,
|
'--scrollbox-vertical-right-padding': `${verticalRightPadding}px`,
|
||||||
'--scrollbox-horizontal-bottom-padding': `${horizontalBottomPadding}px`
|
'--scrollbox-horizontal-bottom-padding': `${horizontalBottomPadding}px`
|
||||||
}}
|
}}
|
||||||
|
className={`${inner ? 'scrollbox-inner' : ''} ${smallPadding ? 'scrollbox-inner-small-padding' : ''}`}
|
||||||
>
|
>
|
||||||
<SimpleBar style={{ height: '100%', ...style }} {...rest}>
|
<SimpleBar style={{ height: '100%', ...style }} {...rest}>
|
||||||
{children}
|
{children}
|
||||||
@ -29,7 +32,9 @@ ScrollBox.propTypes = {
|
|||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
horizontalBottomPadding: PropTypes.number,
|
horizontalBottomPadding: PropTypes.number,
|
||||||
verticalRightPadding: PropTypes.number
|
verticalRightPadding: PropTypes.number,
|
||||||
|
inner: PropTypes.bool,
|
||||||
|
smallPadding: PropTypes.bool
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ScrollBox
|
export default ScrollBox
|
||||||
|
|||||||
168
src/components/Dashboard/common/SimplePropertyFilter.jsx
Normal file
168
src/components/Dashboard/common/SimplePropertyFilter.jsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||||
|
import { Flex, Checkbox, Spin, Typography } from 'antd'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
|
import { getModelProperties } from '../../../database/ObjectModels'
|
||||||
|
import ObjectProperty from './ObjectProperty'
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const getOptionKey = (option) => {
|
||||||
|
if (option && typeof option === 'object') {
|
||||||
|
return String(option._id ?? option.type ?? JSON.stringify(option))
|
||||||
|
}
|
||||||
|
return String(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayValue = (option, property) => {
|
||||||
|
if (!property) return option
|
||||||
|
if (property.type === 'object') {
|
||||||
|
if (option && typeof option === 'object' && option._id) return option
|
||||||
|
if (option != null) return { _id: option }
|
||||||
|
}
|
||||||
|
if (property.type === 'state') {
|
||||||
|
if (option && typeof option === 'object' && option.type) return option
|
||||||
|
if (option != null) return { type: option }
|
||||||
|
}
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchOptions = (options, selected) => {
|
||||||
|
if (!selected?.length) return []
|
||||||
|
const selectedKeys = new Set(selected.map(getOptionKey))
|
||||||
|
return options.filter((option) => selectedKeys.has(getOptionKey(option)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const SimplePropertyFilter = ({
|
||||||
|
modelType,
|
||||||
|
propertyName,
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
search = ''
|
||||||
|
}) => {
|
||||||
|
const { getModelPropertyValues } = useContext(ApiServerContext)
|
||||||
|
const [options, setOptions] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [localChecked, setLocalChecked] = useState(null)
|
||||||
|
|
||||||
|
const property = useMemo(
|
||||||
|
() =>
|
||||||
|
getModelProperties(modelType).find((prop) => prop.name === propertyName),
|
||||||
|
[modelType, propertyName]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
if (!modelType || !propertyName) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = await getModelPropertyValues(modelType, propertyName)
|
||||||
|
if (cancelled) return
|
||||||
|
const unique = [
|
||||||
|
...new Set((values || []).filter((v) => v != null && v !== ''))
|
||||||
|
]
|
||||||
|
setOptions(unique)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOptions()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [modelType, propertyName, getModelPropertyValues])
|
||||||
|
|
||||||
|
const valueKey = useMemo(
|
||||||
|
() => JSON.stringify((value || []).map(getOptionKey)),
|
||||||
|
[value]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync checkbox state from the applied filter (sidebar / column filteredValue).
|
||||||
|
// No active filter => all options checked by default.
|
||||||
|
useEffect(() => {
|
||||||
|
if (options.length === 0) return
|
||||||
|
if (value?.length > 0) {
|
||||||
|
const matched = matchOptions(options, value)
|
||||||
|
setLocalChecked(matched.length > 0 ? matched : [...value])
|
||||||
|
} else {
|
||||||
|
setLocalChecked(options)
|
||||||
|
}
|
||||||
|
// valueKey captures value contents; value is read for matching
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [options, valueKey])
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
if (!query) return options
|
||||||
|
return options.filter((option) =>
|
||||||
|
getOptionKey(option).toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}, [options, search])
|
||||||
|
|
||||||
|
const checkedValues = localChecked ?? options
|
||||||
|
|
||||||
|
const emitChange = (next) => {
|
||||||
|
setLocalChecked(next)
|
||||||
|
// All selected (or empty options) means no filter applied
|
||||||
|
if (options.length > 0 && next.length === options.length) {
|
||||||
|
onChange?.([])
|
||||||
|
} else {
|
||||||
|
onChange?.(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (visibleChecked) => {
|
||||||
|
const hiddenSelected = checkedValues.filter(
|
||||||
|
(checked) =>
|
||||||
|
!filteredOptions.some(
|
||||||
|
(option) => getOptionKey(option) === getOptionKey(checked)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
emitChange([...hiddenSelected, ...visibleChecked])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
|
||||||
|
<Checkbox.Group
|
||||||
|
value={checkedValues}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={16} style={{ minWidth: 0 }}>
|
||||||
|
{filteredOptions.map((option) => (
|
||||||
|
<Flex gap={14} key={getOptionKey(option)} align='center'>
|
||||||
|
<Checkbox value={option} style={{ minWidth: 0 }}></Checkbox>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
{property ? (
|
||||||
|
<ObjectProperty
|
||||||
|
{...property}
|
||||||
|
modelType={modelType}
|
||||||
|
value={getDisplayValue(option, property)}
|
||||||
|
inTable={true}
|
||||||
|
isEditing={false}
|
||||||
|
showLabel={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>{String(option)}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Spin>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SimplePropertyFilter.propTypes = {
|
||||||
|
modelType: PropTypes.string,
|
||||||
|
propertyName: PropTypes.string,
|
||||||
|
value: PropTypes.array,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
search: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SimplePropertyFilter
|
||||||
Loading…
x
Reference in New Issue
Block a user