Skip to content

Commit e622b6e

Browse files
committed
improvement(tables): improve table filtering UX
- Replace popover filter with persistent inline panel below toolbar - Add AND/OR toggle between filter rules (shown in Where label slot) - Sync filter panel state from applied filter on open - Show filter button active state when filter is applied or panel is open - Use readable operator labels matching dropdown options - Add Clear filters button (shown only when filter is active) - Close filter panel when last rule is removed via X - Fix empty gap rows appearing in filtered results by skipping position gap rendering when filter is active - Add toggle mode to ResourceOptionsBar for inline panel pattern - Memoize FilterRuleRow for perf, fix filterTags key collision, remove dead filterActiveCount prop
1 parent 30377d7 commit e622b6e

File tree

3 files changed

+261
-158
lines changed

3 files changed

+261
-158
lines changed

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ import { cn } from '@/lib/core/utils/cn'
1919
const SEARCH_ICON = (
2020
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
2121
)
22-
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
23-
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
2422

2523
type SortDirection = 'asc' | 'desc'
2624

@@ -67,7 +65,12 @@ export interface SearchConfig {
6765
interface ResourceOptionsBarProps {
6866
search?: SearchConfig
6967
sort?: SortConfig
68+
/** Popover content — renders inside a Popover (used by logs, etc.) */
7069
filter?: ReactNode
70+
/** When provided, Filter button acts as a toggle instead of opening a Popover */
71+
onFilterToggle?: () => void
72+
/** Whether the filter is currently active (highlights the toggle button) */
73+
filterActive?: boolean
7174
filterTags?: FilterTag[]
7275
extras?: ReactNode
7376
}
@@ -76,10 +79,13 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
7679
search,
7780
sort,
7881
filter,
82+
onFilterToggle,
83+
filterActive,
7984
filterTags,
8085
extras,
8186
}: ResourceOptionsBarProps) {
82-
const hasContent = search || sort || filter || extras || (filterTags && filterTags.length > 0)
87+
const hasContent =
88+
search || sort || filter || onFilterToggle || extras || (filterTags && filterTags.length > 0)
8389
if (!hasContent) return null
8490

8591
return (
@@ -88,38 +94,53 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
8894
{search && <SearchSection search={search} />}
8995
<div className='flex items-center gap-1.5'>
9096
{extras}
91-
{filterTags?.map((tag) => (
97+
{filterTags?.map((tag, i) => (
9298
<Button
93-
key={tag.label}
99+
key={`${tag.label}-${i}`}
94100
variant='subtle'
95-
className='px-2 py-1 text-caption'
101+
className='max-w-[200px] px-2 py-1 text-caption'
96102
onClick={tag.onRemove}
97103
>
98-
{tag.label}
99-
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
104+
<span className='truncate'>{tag.label}</span>
105+
<span className='ml-1 shrink-0 text-[var(--text-icon)] text-micro'></span>
100106
</Button>
101107
))}
102-
{filter && (
108+
{onFilterToggle ? (
109+
<Button
110+
variant='subtle'
111+
className={cn(
112+
'px-2 py-1 text-caption',
113+
filterActive && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
114+
)}
115+
onClick={onFilterToggle}
116+
>
117+
<ListFilter
118+
className={cn(
119+
'mr-1.5 h-[14px] w-[14px]',
120+
filterActive ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
121+
)}
122+
/>
123+
Filter
124+
</Button>
125+
) : filter ? (
103126
<PopoverPrimitive.Root>
104127
<PopoverPrimitive.Trigger asChild>
105128
<Button variant='subtle' className='px-2 py-1 text-caption'>
106-
{FILTER_ICON}
129+
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
107130
Filter
108131
</Button>
109132
</PopoverPrimitive.Trigger>
110133
<PopoverPrimitive.Portal>
111134
<PopoverPrimitive.Content
112135
align='start'
113136
sideOffset={6}
114-
className={cn(
115-
'z-50 rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
116-
)}
137+
className='z-50 rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
117138
>
118139
{filter}
119140
</PopoverPrimitive.Content>
120141
</PopoverPrimitive.Portal>
121142
</PopoverPrimitive.Root>
122-
)}
143+
) : null}
123144
{sort && <SortDropdown config={sort} />}
124145
</div>
125146
</div>
@@ -213,8 +234,19 @@ const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig
213234
return (
214235
<DropdownMenu>
215236
<DropdownMenuTrigger asChild>
216-
<Button variant='subtle' className='px-2 py-1 text-caption'>
217-
{SORT_ICON}
237+
<Button
238+
variant='subtle'
239+
className={cn(
240+
'px-2 py-1 text-caption',
241+
active && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
242+
)}
243+
>
244+
<ArrowUpDown
245+
className={cn(
246+
'mr-1.5 h-[14px] w-[14px]',
247+
active ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
248+
)}
249+
/>
218250
Sort
219251
</Button>
220252
</DropdownMenuTrigger>

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx

Lines changed: 106 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useMemo, useState } from 'react'
3+
import { memo, useCallback, useMemo, useState } from 'react'
44
import { X } from 'lucide-react'
55
import { nanoid } from 'nanoid'
66
import {
@@ -11,29 +11,26 @@ import {
1111
DropdownMenuTrigger,
1212
} from '@/components/emcn'
1313
import { ChevronDown, Plus } from '@/components/emcn/icons'
14-
import { cn } from '@/lib/core/utils/cn'
1514
import type { Filter, FilterRule } from '@/lib/table'
1615
import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants'
17-
import { filterRulesToFilter } from '@/lib/table/query-builder/converters'
18-
19-
const OPERATOR_LABELS: Record<string, string> = {
20-
eq: '=',
21-
ne: '≠',
22-
gt: '>',
23-
gte: '≥',
24-
lt: '<',
25-
lte: '≤',
26-
contains: '∋',
27-
in: '∈',
28-
} as const
16+
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
17+
18+
const OPERATOR_LABELS = Object.fromEntries(
19+
COMPARISON_OPERATORS.map((op) => [op.value, op.label])
20+
) as Record<string, string>
2921

3022
interface TableFilterProps {
3123
columns: Array<{ name: string; type: string }>
24+
filter: Filter | null
3225
onApply: (filter: Filter | null) => void
26+
onClose: () => void
3327
}
3428

35-
export function TableFilter({ columns, onApply }: TableFilterProps) {
36-
const [rules, setRules] = useState<FilterRule[]>(() => [createRule(columns)])
29+
export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) {
30+
const [rules, setRules] = useState<FilterRule[]>(() => {
31+
const fromFilter = filterToRules(filter)
32+
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
33+
})
3734

3835
const columnOptions = useMemo(
3936
() => columns.map((col) => ({ value: col.name, label: col.name })),
@@ -46,71 +43,122 @@ export function TableFilter({ columns, onApply }: TableFilterProps) {
4643

4744
const handleRemove = useCallback(
4845
(id: string) => {
49-
setRules((prev) => {
50-
const next = prev.filter((r) => r.id !== id)
51-
return next.length === 0 ? [createRule(columns)] : next
52-
})
46+
const next = rules.filter((r) => r.id !== id)
47+
if (next.length === 0) {
48+
onApply(null)
49+
onClose()
50+
setRules([createRule(columns)])
51+
} else {
52+
setRules(next)
53+
}
5354
},
54-
[columns]
55+
[columns, onApply, onClose, rules]
5556
)
5657

5758
const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => {
5859
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
5960
}, [])
6061

62+
const handleToggleLogical = useCallback((id: string) => {
63+
setRules((prev) =>
64+
prev.map((r) =>
65+
r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r
66+
)
67+
)
68+
}, [])
69+
6170
const handleApply = useCallback(() => {
6271
const validRules = rules.filter((r) => r.column && r.value)
6372
onApply(filterRulesToFilter(validRules))
6473
}, [rules, onApply])
6574

66-
return (
67-
<div className='flex flex-col gap-1.5 p-2'>
68-
{rules.map((rule) => (
69-
<FilterRuleRow
70-
key={rule.id}
71-
rule={rule}
72-
columns={columnOptions}
73-
onUpdate={handleUpdate}
74-
onRemove={handleRemove}
75-
onApply={handleApply}
76-
/>
77-
))}
78-
79-
<div className='flex items-center justify-between gap-3'>
80-
<Button
81-
variant='ghost'
82-
size='sm'
83-
onClick={handleAdd}
84-
className={cn(
85-
'border border-[var(--border)] border-dashed px-2 py-[3px] text-[var(--text-secondary)] text-xs'
86-
)}
87-
>
88-
<Plus className='mr-1 h-[10px] w-[10px]' />
89-
Add filter
90-
</Button>
75+
const handleClear = useCallback(() => {
76+
setRules([createRule(columns)])
77+
onApply(null)
78+
}, [columns, onApply])
9179

92-
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
93-
Apply filter
94-
</Button>
80+
return (
81+
<div className='border-[var(--border)] border-b bg-[var(--bg)] px-4 py-2'>
82+
<div className='flex flex-col gap-1'>
83+
{rules.map((rule, index) => (
84+
<FilterRuleRow
85+
key={rule.id}
86+
rule={rule}
87+
isFirst={index === 0}
88+
columns={columnOptions}
89+
onUpdate={handleUpdate}
90+
onRemove={handleRemove}
91+
onApply={handleApply}
92+
onToggleLogical={handleToggleLogical}
93+
/>
94+
))}
95+
96+
<div className='mt-1 flex items-center justify-between'>
97+
<Button
98+
variant='ghost'
99+
size='sm'
100+
onClick={handleAdd}
101+
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
102+
>
103+
<Plus className='mr-1 h-[10px] w-[10px]' />
104+
Add filter
105+
</Button>
106+
<div className='flex items-center gap-1.5'>
107+
{filter !== null && (
108+
<Button
109+
variant='ghost'
110+
size='sm'
111+
onClick={handleClear}
112+
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
113+
>
114+
Clear filters
115+
</Button>
116+
)}
117+
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
118+
Apply filter
119+
</Button>
120+
</div>
121+
</div>
95122
</div>
96123
</div>
97124
)
98125
}
99126

100127
interface FilterRuleRowProps {
101128
rule: FilterRule
129+
isFirst: boolean
102130
columns: Array<{ value: string; label: string }>
103131
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
104132
onRemove: (id: string) => void
105133
onApply: () => void
134+
onToggleLogical: (id: string) => void
106135
}
107136

108-
function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRuleRowProps) {
137+
const FilterRuleRow = memo(function FilterRuleRow({
138+
rule,
139+
isFirst,
140+
columns,
141+
onUpdate,
142+
onRemove,
143+
onApply,
144+
onToggleLogical,
145+
}: FilterRuleRowProps) {
109146
return (
110-
<div className='flex items-center gap-1'>
147+
<div className='flex items-center gap-1.5'>
148+
{isFirst ? (
149+
<span className='w-[42px] shrink-0 text-right text-[var(--text-muted)] text-xs'>Where</span>
150+
) : (
151+
<button
152+
onClick={() => onToggleLogical(rule.id)}
153+
className='w-[42px] shrink-0 rounded-full py-0.5 text-right font-medium text-[10px] text-[var(--text-muted)] uppercase tracking-wide transition-colors hover:text-[var(--text-secondary)]'
154+
>
155+
{rule.logicalOperator}
156+
</button>
157+
)}
158+
111159
<DropdownMenu>
112160
<DropdownMenuTrigger asChild>
113-
<button className='flex h-[30px] min-w-[100px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
161+
<button className='flex h-[28px] min-w-[100px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
114162
<span className='truncate'>{rule.column || 'Column'}</span>
115163
<ChevronDown className='ml-1 h-[10px] w-[10px] shrink-0 text-[var(--text-icon)]' />
116164
</button>
@@ -129,8 +177,8 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul
129177

130178
<DropdownMenu>
131179
<DropdownMenuTrigger asChild>
132-
<button className='flex h-[30px] min-w-[50px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
133-
<span>{OPERATOR_LABELS[rule.operator] ?? rule.operator}</span>
180+
<button className='flex h-[28px] min-w-[90px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
181+
<span className='truncate'>{OPERATOR_LABELS[rule.operator] ?? rule.operator}</span>
134182
<ChevronDown className='ml-1 h-[10px] w-[10px] shrink-0 text-[var(--text-icon)]' />
135183
</button>
136184
</DropdownMenuTrigger>
@@ -151,25 +199,21 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul
151199
value={rule.value}
152200
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
153201
onKeyDown={(e) => {
154-
if (e.key === 'Enter') handleApply()
202+
if (e.key === 'Enter') onApply()
155203
}}
156204
placeholder='Enter a value'
157-
className='h-[30px] min-w-[160px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
205+
className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
158206
/>
159207

160208
<button
161209
onClick={() => onRemove(rule.id)}
162-
className='flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[5px] text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
210+
className='flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[5px] text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
163211
>
164212
<X className='h-[12px] w-[12px]' />
165213
</button>
166214
</div>
167215
)
168-
169-
function handleApply() {
170-
onApply()
171-
}
172-
}
216+
})
173217

174218
function createRule(columns: Array<{ name: string }>): FilterRule {
175219
return {

0 commit comments

Comments
 (0)