Add QuickPropertyFilters Component and Enhance FilterInput and ObjectTable

- Introduced QuickPropertyFilters component for streamlined property filtering functionality.
- Updated FilterInput to integrate QuickPropertyFilters, enhancing user experience with quick access to filter options.
- Modified ObjectTable to include QuickPropertyFilters, improving filtering capabilities within the dashboard.
- Adjusted styles in App.css for dropdown z-index and input font consistency.
This commit is contained in:
Tom Butcher 2026-09-01 18:44:40 +01:00
parent 1597370a16
commit 3352c25819
4 changed files with 534 additions and 8 deletions

View File

@ -47,6 +47,10 @@
font-family: 'DM Sans';
}
.ant-dropdown {
z-index: 999;
}
.ant-typography code,
.ant-typography pre,
.ͼ1 .cm-scroller {
@ -793,7 +797,7 @@ body {
}
.input-number-cal .ant-input {
font-family: 'DM Mono';
font-family: 'DM Sans';
font-weight: 400;
}

View File

@ -17,6 +17,7 @@ import { useCompactItemContext } from 'antd/es/space/Compact'
import useStyle, { useSharedStyle } from 'antd/es/input/style'
import { useThemeContext } from '../context/ThemeContext'
import SimplePropertyFilter from './SimplePropertyFilter'
import QuickPropertyFilters from './QuickPropertyFilters'
import ScrollBox from './ScrollBox'
import ChevronRightIcon from '../../Icons/ChevronRightIcon'
import GreaterThanIcon from '../../Icons/GreaterThanIcon'
@ -25,9 +26,12 @@ import LessThanIcon from '../../Icons/LessThanIcon'
import LessThanOrEqualToIcon from '../../Icons/LessThanOrEqualToIcon'
import NotEqualIcon from '../../Icons/NotEqualIcon'
import EqualIcon from '../../Icons/EqualIcon'
import { Divider } from 'antd'
const operandIconStyle = { fontSize: 8 }
const PROPERTY_FILTER_PANEL_MAX_HEIGHT = 220
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
// Wildcards (* ?) are highlighted in place; @ stays as plain text — none are operands.
const WILDCARDS = new Set(['*', '?'])
@ -469,8 +473,12 @@ const FilterInput = ({
const composingRef = useRef(false)
const selectingRef = useRef(false)
const valueRef = useRef(value ?? '')
const quickFiltersRef = useRef(null)
const quickFilterModalOpenRef = useRef(false)
const [focused, setFocused] = useState(false)
const [internalValue, setInternalValue] = useState(value ?? '')
const [quickFiltersHeight, setQuickFiltersHeight] = useState(0)
const [quickFilterModalOpen, setQuickFilterModalOpen] = useState(false)
const clearTagRoots = useCallback(() => {
tagRootsRef.current.forEach((root) => {
@ -864,6 +872,48 @@ const FilterInput = ({
[emitChange, paint]
)
const handleQuickFilterModalOpenChange = useCallback((open) => {
quickFilterModalOpenRef.current = open
setQuickFilterModalOpen(open)
if (open) {
editorRef.current?.blur()
focusedRef.current = false
setFocused(false)
} else {
requestAnimationFrame(() => {
editorRef.current?.focus()
focusedRef.current = true
setFocused(true)
})
}
}, [])
const propertyFilterPopoverOpen = focused || quickFilterModalOpen
useEffect(() => {
if (!propertyFilterEnabled) {
setQuickFiltersHeight(0)
return
}
const node = quickFiltersRef.current
if (!node) return
const update = () => {
setQuickFiltersHeight(node.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [
propertyFilterEnabled,
propertyFilter?.modelType,
propertyFilter?.propertyName,
focused
])
const propertyFilterContent = propertyFilterEnabled ? (
<div
onMouseDown={(event) => {
@ -872,17 +922,33 @@ const FilterInput = ({
}}
className='filter-input-property-filter'
style={{
display: 'inline-block',
display: 'inline-flex',
flexDirection: 'column',
maxWidth: 280,
maxHeight: 220,
maxHeight: quickFiltersHeight + PROPERTY_FILTER_PANEL_MAX_HEIGHT,
margin: -4,
verticalAlign: 'top'
verticalAlign: 'top',
overflow: 'hidden'
}}
>
<div ref={quickFiltersRef} style={{ flexShrink: 0 }}>
<QuickPropertyFilters
modelType={propertyFilter.modelType}
propertyName={propertyFilter.propertyName}
onChange={handlePropertyFilterChange}
onModalOpenChange={handleQuickFilterModalOpenChange}
useCard={false}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<ScrollBox
inner
smallPadding
style={{ height: 'auto', maxHeight: 220, maxWidth: 280 }}
style={{
flexShrink: 0,
maxHeight: PROPERTY_FILTER_PANEL_MAX_HEIGHT,
maxWidth: 280
}}
>
<div
style={{
@ -1031,7 +1097,9 @@ const FilterInput = ({
zIndex: focused ? 3 : style?.zIndex
}}
onClick={() => {
if (!disabled) editorRef.current?.focus()
if (!disabled && !quickFilterModalOpenRef.current) {
editorRef.current?.focus()
}
}}
>
<style>{`
@ -1100,6 +1168,11 @@ const FilterInput = ({
onFocus?.(event)
}}
onBlur={(event) => {
if (quickFilterModalOpenRef.current) {
focusedRef.current = false
setFocused(false)
return
}
focusedRef.current = false
selectingRef.current = false
setFocused(false)
@ -1151,9 +1224,9 @@ const FilterInput = ({
)}
{propertyFilterEnabled && (
<Popover
open={focused}
open={propertyFilterPopoverOpen}
destroyOnHidden={true}
content={focused ? propertyFilterContent : null}
content={propertyFilterPopoverOpen ? propertyFilterContent : null}
placement='bottomLeft'
arrow={false}
trigger={[]}

View File

@ -49,6 +49,7 @@ import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
import SimplePropertyFilter from './SimplePropertyFilter'
import QuickPropertyFilters from './QuickPropertyFilters'
import FilterInput from './FilterInput'
import {
getActiveFilterValues,
@ -159,6 +160,11 @@ const ColumnFilterDropdown = ({
<Button onClick={resetFilter} icon={<XMarkIcon />} />
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
</Space.Compact>
<QuickPropertyFilters
modelType={modelType}
propertyName={propertyName}
onChange={handleDraftChange}
/>
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
<ScrollBox inner={true} smallPadding={true}>
<div style={{ padding: '18px 20px', minWidth: 0 }}>

View File

@ -0,0 +1,443 @@
import { useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Descriptions,
Flex,
Input,
Modal,
Typography
} from 'antd'
import PropTypes from 'prop-types'
import dayjs from 'dayjs'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import InputNumberCal from './InputNumberCal'
import {
getModelByName,
getModelProperties
} from '../../../database/ObjectModels'
import { isDateTimePropertyType } from './filterExpression'
const { Text } = Typography
const TEXT_TYPES = new Set([
'text',
'email',
'url',
'codeBlock',
'markdown',
'tags',
'stringList',
'country',
'phone',
'address',
'secret',
'miscId'
])
const NUMBER_TYPES = new Set(['number', 'density', 'variance', 'numberList'])
const BOOL_TYPES = new Set(['bool', 'boolean'])
const OBJECT_TYPES = new Set([
'object',
'objectList',
'reference',
'state',
'id',
'objectType'
])
const formatFilterValue = (value, property) => {
if (value == null || value === '') return ''
if (typeof value !== 'object') return String(value)
if (property?.type === 'state') {
return String(value.type ?? value)
}
if (value.objectType) {
const { prefix } = getModelByName(value.objectType)
if (value._reference != null) return `${prefix}:${value._reference}`
if (value._id != null) return `${prefix}:${value._id}`
}
if (property?.objectType && value._id != null) {
const { prefix } = getModelByName(property.objectType)
return `${prefix}:${value._id}`
}
return String(value._id ?? value.type ?? JSON.stringify(value))
}
const formatDateFilterValue = (value, property) => {
if (value == null || value === '') return ''
const date = dayjs(value)
if (!date.isValid()) return String(value)
if (property?.type === 'date') {
return `${date.date()} ${date.month() + 1} ${date.year()}`
}
return date.toISOString()
}
const buildExpression = (operator, rawValue, property) => {
const value = isDateTimePropertyType(property)
? formatDateFilterValue(rawValue, property)
: formatFilterValue(rawValue, property)
switch (operator) {
case 'startsWith':
return `${value}*`
case 'notStartsWith':
return `<>${value}*`
case 'contains':
return `*${value}*`
case 'notContains':
return `<>*${value}*`
case 'endsWith':
return `*${value}`
case 'notEndsWith':
return `<>*${value}`
case 'equals':
return value
case 'notEquals':
return `<>${value}`
case 'greaterThan':
return `>${value}`
case 'greaterOrEqual':
return `>=${value}`
case 'lessThan':
return `<${value}`
case 'lessOrEqual':
return `<=${value}`
case 'between':
return value
case 'isTrue':
return 'true'
case 'isFalse':
return 'false'
case 'isEmpty':
return ''
default:
return value
}
}
const getPropertyCategory = (property) => {
const type = property?.type
if (!type) return 'text'
if (BOOL_TYPES.has(type)) return 'boolean'
if (isDateTimePropertyType(property)) return 'date'
if (NUMBER_TYPES.has(type)) return 'number'
if (OBJECT_TYPES.has(type)) return 'object'
if (TEXT_TYPES.has(type)) return 'text'
return 'text'
}
const getFilterOptions = (category) => {
switch (category) {
case 'boolean':
return [
{ key: 'isTrue', label: 'Is true' },
{ key: 'isFalse', label: 'Is false' }
]
case 'number':
return [
{ key: 'equals', label: 'Equals', needsValue: true },
{ key: 'notEquals', label: 'Does not equal', needsValue: true },
{ key: 'greaterThan', label: 'Greater than', needsValue: true },
{
key: 'greaterOrEqual',
label: 'Greater than or equal',
needsValue: true
},
{ key: 'lessThan', label: 'Less than', needsValue: true },
{
key: 'lessOrEqual',
label: 'Less than or equal',
needsValue: true
},
{ key: 'between', label: 'Between', needsRange: true }
]
case 'date':
return [
{ key: 'equals', label: 'On', needsValue: true },
{ key: 'lessThan', label: 'Before', needsValue: true },
{ key: 'greaterThan', label: 'After', needsValue: true },
{ key: 'between', label: 'Between', needsRange: true }
]
case 'object':
return [
{ key: 'equals', label: 'Equals', needsValue: true },
{ key: 'notEquals', label: 'Does not equal', needsValue: true }
]
case 'text':
default:
return [
{ key: 'startsWith', label: 'Starts with', needsValue: true },
{
key: 'notStartsWith',
label: 'Does not start with',
needsValue: true
},
{ key: 'contains', label: 'Contains', needsValue: true },
{ key: 'notContains', label: 'Does not contain', needsValue: true },
{ key: 'endsWith', label: 'Ends with', needsValue: true },
{ key: 'notEndsWith', label: 'Does not end with', needsValue: true },
{ key: 'equals', label: 'Equals', needsValue: true },
{ key: 'notEquals', label: 'Does not equal', needsValue: true },
{ key: 'isEmpty', label: 'Is empty' }
]
}
}
const QuickPropertyFilters = ({
modelType,
propertyName,
onChange,
onModalOpenChange,
useCard = true
}) => {
const [activeOption, setActiveOption] = useState(null)
const [inputValue, setInputValue] = useState(null)
const [rangeFrom, setRangeFrom] = useState(null)
const [rangeTo, setRangeTo] = useState(null)
const modalContentRef = useRef(null)
const property = useMemo(
() =>
getModelProperties(modelType).find((prop) => prop.name === propertyName),
[modelType, propertyName]
)
const category = useMemo(() => getPropertyCategory(property), [property])
const options = useMemo(() => getFilterOptions(category), [category])
const propertyLabel = property?.label || propertyName
const closeModal = () => {
onModalOpenChange?.(false)
setActiveOption(null)
setInputValue(null)
setRangeFrom(null)
setRangeTo(null)
}
const applyFilter = (operator, rawValue) => {
const expression = buildExpression(operator, rawValue, property)
onChange?.([expression])
closeModal()
}
const handleOptionClick = (option) => {
if (option.key === 'isTrue' || option.key === 'isFalse') {
applyFilter(option.key)
return
}
if (option.key === 'isEmpty') {
applyFilter('isEmpty')
return
}
setInputValue(null)
setRangeFrom(null)
setRangeTo(null)
onModalOpenChange?.(true)
setActiveOption(option)
}
const handleModalOk = () => {
if (!activeOption) return
if (activeOption.needsRange) {
const from = isDateTimePropertyType(property)
? formatDateFilterValue(rangeFrom, property)
: formatFilterValue(rangeFrom, property)
const to = isDateTimePropertyType(property)
? formatDateFilterValue(rangeTo, property)
: formatFilterValue(rangeTo, property)
if (!from && !to) return
applyFilter('between', `${from}..${to}`)
return
}
if (
inputValue == null ||
(typeof inputValue === 'string' && inputValue.trim() === '')
) {
return
}
applyFilter(activeOption.key, inputValue)
}
const focusFirstModalInput = () => {
requestAnimationFrame(() => {
modalContentRef.current
?.querySelector('input, textarea, .ant-picker-input input')
?.focus()
})
}
const renderValueInput = (value, onValueChange) => {
if (isDateTimePropertyType(property)) {
const pickerValue =
value == null ? null : dayjs.isDayjs(value) ? value : dayjs(value)
const validValue =
pickerValue && pickerValue.isValid() ? pickerValue : null
return (
<DatePicker
style={{ width: '100%' }}
showTime={property?.type === 'dateTime'}
value={validValue}
onChange={onValueChange}
/>
)
}
if (NUMBER_TYPES.has(property?.type)) {
return (
<InputNumberCal
style={{ width: '100%' }}
value={value}
onChange={onValueChange}
/>
)
}
return (
<Input
type='text'
value={value ?? ''}
onChange={(event) => onValueChange(event.target.value)}
/>
)
}
if (!property) return null
const content = (
<Flex vertical gap={0}>
{options.map((option) => (
<Button
key={option.key}
type='text'
block
style={{
justifyContent: 'flex-start',
textAlign: 'left',
padding: '5px 12px'
}}
onClick={() => handleOptionClick(option)}
>
{option.label}
</Button>
))}
</Flex>
)
return (
<>
{useCard ? (
<Card size='small' styles={{ body: { padding: '4px 4px' } }}>
{content}
</Card>
) : (
content
)}
<Modal
open={activeOption != null}
onCancel={closeModal}
afterOpenChange={(open) => {
if (open) focusFirstModalInput()
}}
destroyOnHidden
focusTriggerAfterClose={false}
footer={null}
centered
closeIcon={null}
getContainer={() => document.body}
width={520}
>
{activeOption && (
<Flex
ref={modalContentRef}
vertical
gap='middle'
onMouseDown={(event) => event.stopPropagation()}
>
<Flex gap='middle'>
<InfoCircleIcon />
<Text strong>Filter by {propertyLabel}</Text>
</Flex>
<Text>
{activeOption.needsRange
? `Enter a range to continue:`
: `Enter a value to continue:`}
</Text>
{activeOption.needsRange ? (
<Flex vertical gap='middle'>
<Descriptions
column={1}
size='small'
styles={{ label: { width: '60px' } }}
items={[
{
key: 'from',
label: (
<Flex
vertical
style={{ height: '100%' }}
justify='center'
>
From
</Flex>
),
children: renderValueInput(rangeFrom, setRangeFrom)
},
{
key: 'to',
label: (
<Flex
vertical
style={{ height: '100%' }}
justify='center'
>
To
</Flex>
),
children: renderValueInput(rangeTo, setRangeTo)
}
]}
/>
</Flex>
) : activeOption.needsValue ? (
renderValueInput(inputValue, setInputValue)
) : null}
<Flex justify='end' gap='small'>
<Button type='default' onClick={closeModal}>
Cancel
</Button>
<Button type='primary' onClick={handleModalOk}>
Apply
</Button>
</Flex>
</Flex>
)}
</Modal>
</>
)
}
QuickPropertyFilters.propTypes = {
modelType: PropTypes.string,
propertyName: PropTypes.string,
onChange: PropTypes.func,
onModalOpenChange: PropTypes.func,
useCard: PropTypes.bool
}
export default QuickPropertyFilters