From 0b110cdfffb795199ffc320591adb0decf6bdc71 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 10 Aug 2026 00:42:09 +0100 Subject: [PATCH] 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. --- assets/stylesheets/App.css | 18 ++ .../Dashboard/common/ObjectTable.jsx | 181 ++++++++++++++---- src/components/Dashboard/common/ScrollBox.jsx | 7 +- .../Dashboard/common/SimplePropertyFilter.jsx | 168 ++++++++++++++++ 4 files changed, 335 insertions(+), 39 deletions(-) create mode 100644 src/components/Dashboard/common/SimplePropertyFilter.jsx diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 773bbb2..4d567f8 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -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; } diff --git a/src/components/Dashboard/common/ObjectTable.jsx b/src/components/Dashboard/common/ObjectTable.jsx index 81337d4..0a67f1a 100644 --- a/src/components/Dashboard/common/ObjectTable.jsx +++ b/src/components/Dashboard/common/ObjectTable.jsx @@ -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 ( +
+ + + +
+ ) +} + +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 ( -
- - - setSelectedKeys(e.target.value ? [e.target.value] : []) - } - onPressEnter={() => confirm()} - style={{ width: 200, display: 'block' }} - /> -
- ) - } + visible, + propertyName, + propertyLabel + }) => ( + + ) 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) diff --git a/src/components/Dashboard/common/ScrollBox.jsx b/src/components/Dashboard/common/ScrollBox.jsx index 3c66096..2a3db87 100644 --- a/src/components/Dashboard/common/ScrollBox.jsx +++ b/src/components/Dashboard/common/ScrollBox.jsx @@ -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' : ''}`} > {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 diff --git a/src/components/Dashboard/common/SimplePropertyFilter.jsx b/src/components/Dashboard/common/SimplePropertyFilter.jsx new file mode 100644 index 0000000..7c98e16 --- /dev/null +++ b/src/components/Dashboard/common/SimplePropertyFilter.jsx @@ -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 ( + }> + + + {filteredOptions.map((option) => ( + + +
+ {property ? ( + + ) : ( + {String(option)} + )} +
+
+ ))} +
+
+
+ ) +} + +SimplePropertyFilter.propTypes = { + modelType: PropTypes.string, + propertyName: PropTypes.string, + value: PropTypes.array, + onChange: PropTypes.func, + search: PropTypes.string +} + +export default SimplePropertyFilter