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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: #78787854 !important;
|
||||
}
|
||||
|
||||
@ -22,7 +22,8 @@ import {
|
||||
Space,
|
||||
Tooltip,
|
||||
Form,
|
||||
Splitter
|
||||
Splitter,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import PropTypes from 'prop-types'
|
||||
@ -49,6 +50,7 @@ import { useActions } from '../context/ActionsContext'
|
||||
import ActionsIcon from '../../Icons/ActionsIcon'
|
||||
import FilterIcon from '../../Icons/FilterIcon'
|
||||
import ScrollBox from './ScrollBox'
|
||||
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||
import {
|
||||
getActiveFilterValues,
|
||||
useTableStatePersistence
|
||||
@ -68,6 +70,120 @@ const getCardColSpan = (containerWidth) => {
|
||||
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 [form] = Form.useForm()
|
||||
useEffect(() => {
|
||||
@ -973,36 +1089,21 @@ const ObjectTable = forwardRef(
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
propertyName
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
<Space.Compact>
|
||||
<Input
|
||||
placeholder={'Search ' + propertyName}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
style={{ width: 200, display: 'block' }}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
clearFilters()
|
||||
confirm()
|
||||
}}
|
||||
icon={<XMarkIcon />}
|
||||
/>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => confirm()}
|
||||
icon={<CheckIcon />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
visible,
|
||||
propertyName,
|
||||
propertyLabel
|
||||
}) => (
|
||||
<ColumnFilterDropdown
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
confirm={confirm}
|
||||
clearFilters={clearFilters}
|
||||
visible={visible}
|
||||
propertyName={propertyName}
|
||||
propertyLabel={propertyLabel}
|
||||
modelType={type}
|
||||
/>
|
||||
)
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
if (isInternalSortUpdateRef.current) return
|
||||
@ -1010,8 +1111,9 @@ const ObjectTable = forwardRef(
|
||||
const next = { ...sidebarFilter }
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
next[key] = value[0]
|
||||
const expression = toFilterExpression(value)
|
||||
if (expression !== undefined) {
|
||||
next[key] = expression
|
||||
} else {
|
||||
delete next[key]
|
||||
}
|
||||
@ -1182,18 +1284,21 @@ const ObjectTable = forwardRef(
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters
|
||||
clearFilters,
|
||||
visible
|
||||
}) =>
|
||||
getFilterDropdown({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
propertyName: prop.label
|
||||
visible,
|
||||
propertyName: prop.name,
|
||||
propertyLabel: prop.label
|
||||
})
|
||||
const sidebarVal = sidebarFilter[prop.name]
|
||||
columnConfig.filteredValue =
|
||||
sidebarVal !== undefined && sidebarVal !== '' ? [sidebarVal] : null
|
||||
columnConfig.filteredValue = fromFilterExpression(
|
||||
sidebarFilter[prop.name]
|
||||
)
|
||||
}
|
||||
|
||||
columnsWithSkeleton.push(columnConfig)
|
||||
|
||||
@ -7,6 +7,8 @@ const ScrollBox = ({
|
||||
style,
|
||||
horizontalBottomPadding = 16,
|
||||
verticalRightPadding = 16,
|
||||
inner = false,
|
||||
smallPadding = false,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
@ -17,6 +19,7 @@ const ScrollBox = ({
|
||||
'--scrollbox-vertical-right-padding': `${verticalRightPadding}px`,
|
||||
'--scrollbox-horizontal-bottom-padding': `${horizontalBottomPadding}px`
|
||||
}}
|
||||
className={`${inner ? 'scrollbox-inner' : ''} ${smallPadding ? 'scrollbox-inner-small-padding' : ''}`}
|
||||
>
|
||||
<SimpleBar style={{ height: '100%', ...style }} {...rest}>
|
||||
{children}
|
||||
@ -29,7 +32,9 @@ ScrollBox.propTypes = {
|
||||
children: PropTypes.node,
|
||||
style: PropTypes.object,
|
||||
horizontalBottomPadding: PropTypes.number,
|
||||
verticalRightPadding: PropTypes.number
|
||||
verticalRightPadding: PropTypes.number,
|
||||
inner: PropTypes.bool,
|
||||
smallPadding: PropTypes.bool
|
||||
}
|
||||
|
||||
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