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:
Tom Butcher 2026-08-10 00:42:09 +01:00
parent ed4f1c7505
commit 0b110cdfff
4 changed files with 335 additions and 39 deletions

View File

@ -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;
}

View File

@ -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)

View File

@ -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

View 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