Skip to content

Commit 9fb56ae

Browse files
committed
feat(tables): improve view and filter controls
1 parent 9bdac1d commit 9fb56ae

10 files changed

Lines changed: 433 additions & 104 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { ColumnsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu'
8+
9+
let container: HTMLDivElement
10+
let root: Root
11+
12+
beforeEach(() => {
13+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
14+
container = document.createElement('div')
15+
document.body.appendChild(container)
16+
root = createRoot(container)
17+
})
18+
19+
afterEach(() => {
20+
act(() => root.unmount())
21+
container.remove()
22+
})
23+
24+
describe('ColumnsMenu', () => {
25+
it('uses the app menu typography and icon sizing shared by Sort', () => {
26+
const onChange = vi.fn()
27+
act(() => {
28+
root.render(
29+
<ColumnsMenu
30+
columns={[
31+
{ id: 'col-name', name: 'Name', type: 'string' },
32+
{ id: 'col-email', name: 'Email', type: 'string' },
33+
]}
34+
workflowGroups={[]}
35+
hiddenColumns={[]}
36+
onChange={onChange}
37+
/>
38+
)
39+
})
40+
act(() => {
41+
container
42+
.querySelector<HTMLButtonElement>('button')
43+
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
44+
})
45+
46+
const item = document.body.querySelector<HTMLElement>('[role="menuitem"]')
47+
expect(item).not.toBeNull()
48+
expect(item).toHaveClass('text-small')
49+
expect(item?.querySelector('svg')).toHaveClass('size-[14px]')
50+
51+
act(() => item?.click())
52+
expect(onChange).toHaveBeenCalledWith(['col-name'])
53+
expect(document.body.querySelector('[role="menuitem"]')).not.toBeNull()
54+
})
55+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import { memo, useMemo, useState } from 'react'
44
import {
55
Chip,
66
cn,
7-
POPOVER_ANIMATION_CLASSES,
8-
Popover,
9-
PopoverContent,
10-
PopoverItem,
11-
PopoverSection,
12-
PopoverTrigger,
7+
DropdownMenu,
8+
DropdownMenuContent,
9+
DropdownMenuItem,
10+
DropdownMenuTrigger,
1311
} from '@sim/emcn'
1412
import { Columns3, Eye, EyeOff } from '@sim/emcn/icons'
1513
import type { ColumnDefinition, WorkflowGroup } from '@/lib/table'
@@ -78,30 +76,18 @@ export const ColumnsMenu = memo(function ColumnsMenu({
7876
const hiddenCount = hiddenColumns.length
7977

8078
return (
81-
<Popover size='md' open={open} onOpenChange={setOpen}>
82-
<PopoverTrigger asChild>
79+
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
80+
<DropdownMenuTrigger asChild>
8381
{/* `active` alone signals that something is hidden — the label stays fixed
8482
so the bar doesn't reflow as columns are toggled. */}
8583
<Chip active={hiddenCount > 0} leftIcon={Columns3}>
8684
Columns
8785
</Chip>
88-
</PopoverTrigger>
89-
<PopoverContent
90-
side='bottom'
91-
align='start'
92-
sideOffset={6}
93-
minWidth={240}
94-
maxWidth={320}
95-
maxHeight={420}
96-
border
97-
className={cn(
98-
POPOVER_ANIMATION_CLASSES,
99-
'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
100-
)}
86+
</DropdownMenuTrigger>
87+
<DropdownMenuContent
88+
align='end'
89+
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
10190
>
102-
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
103-
Columns
104-
</PopoverSection>
10591
<div className='flex flex-col gap-0.5'>
10692
{plain.map((col) => {
10793
const id = getColumnId(col)
@@ -144,8 +130,8 @@ export const ColumnsMenu = memo(function ColumnsMenu({
144130
)
145131
})}
146132
</div>
147-
</PopoverContent>
148-
</Popover>
133+
</DropdownMenuContent>
134+
</DropdownMenu>
149135
)
150136
})
151137

@@ -164,14 +150,17 @@ function ColumnToggleRow({ label, visible, partial, indented, onToggle }: Column
164150
const showing = visible || partial
165151
const Icon = showing ? Eye : EyeOff
166152
return (
167-
<PopoverItem
168-
onClick={() => onToggle(!visible)}
169-
className={cn('h-7 items-center gap-1.5 px-1.5 py-0 text-xs', indented && 'pl-5')}
153+
<DropdownMenuItem
154+
onSelect={(event) => {
155+
event.preventDefault()
156+
onToggle(!visible)
157+
}}
158+
className={cn(indented && 'pl-7')}
170159
>
171160
<span className='flex size-[14px] shrink-0 items-center justify-center'>
172161
<Icon
173162
className={cn(
174-
'size-3',
163+
'size-[14px]',
175164
showing ? 'text-[var(--text-icon)]' : 'text-[var(--text-muted)]',
176165
partial && 'opacity-60'
177166
)}
@@ -182,6 +171,6 @@ function ColumnToggleRow({ label, visible, partial, indented, onToggle }: Column
182171
>
183172
{label}
184173
</span>
185-
</PopoverItem>
174+
</DropdownMenuItem>
186175
)
187176
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { TableFilter } from './table-filter'
1+
export { TableFilter, type TableFilterHandle } from './table-filter'
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, createRef, type Ref } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { ColumnDefinition, TablePredicate } from '@/lib/table'
8+
import {
9+
FILTER_DEBOUNCE_MS,
10+
TableFilter,
11+
type TableFilterHandle,
12+
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter'
13+
14+
const COLUMNS: ColumnDefinition[] = [{ id: 'col-name', name: 'Name', type: 'string' }]
15+
16+
let container: HTMLDivElement
17+
let root: Root
18+
19+
beforeEach(() => {
20+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
21+
vi.useFakeTimers()
22+
container = document.createElement('div')
23+
document.body.appendChild(container)
24+
root = createRoot(container)
25+
})
26+
27+
afterEach(() => {
28+
act(() => root.unmount())
29+
container.remove()
30+
vi.useRealTimers()
31+
})
32+
33+
function renderFilter(
34+
onChange: (filter: TablePredicate | null) => void,
35+
filter: TablePredicate | null = null,
36+
ref?: Ref<TableFilterHandle>
37+
) {
38+
act(() => {
39+
root.render(<TableFilter ref={ref} columns={COLUMNS} filter={filter} onChange={onChange} />)
40+
})
41+
}
42+
43+
describe('TableFilter', () => {
44+
it('applies text filters after a short typing delay', () => {
45+
const onApply = vi.fn()
46+
renderFilter(onApply)
47+
const input = container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')
48+
expect(input).not.toBeNull()
49+
50+
act(() => {
51+
if (!input) return
52+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada')
53+
input.dispatchEvent(new Event('input', { bubbles: true }))
54+
})
55+
56+
expect(onApply).not.toHaveBeenCalled()
57+
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1))
58+
expect(onApply).not.toHaveBeenCalled()
59+
act(() => vi.advanceTimersByTime(1))
60+
expect(onApply).toHaveBeenCalledWith({
61+
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
62+
})
63+
})
64+
65+
it('uses fixed AND conjunctions without apply or clear actions', () => {
66+
renderFilter(vi.fn())
67+
const addFilter = Array.from(container.querySelectorAll('button')).find((button) =>
68+
button.textContent?.includes('Add filter')
69+
)
70+
71+
act(() => addFilter?.click())
72+
73+
const conjunction = Array.from(container.querySelectorAll('*')).find(
74+
(element) => element.textContent?.trim() === 'and'
75+
)
76+
expect(conjunction).toBeDefined()
77+
expect(conjunction?.closest('button')).toBeNull()
78+
expect(container.textContent).not.toContain('Apply filter')
79+
expect(container.textContent).not.toContain('Clear filters')
80+
})
81+
82+
it('flushes the pending filter when the panel closes before the delay', () => {
83+
const onChange = vi.fn()
84+
const filterRef = createRef<TableFilterHandle>()
85+
renderFilter(onChange, null, filterRef)
86+
const input = container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')
87+
88+
act(() => {
89+
if (!input) return
90+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada')
91+
input.dispatchEvent(new Event('input', { bubbles: true }))
92+
})
93+
act(() => {
94+
filterRef.current?.flush()
95+
})
96+
97+
expect(onChange).toHaveBeenCalledTimes(1)
98+
expect(onChange).toHaveBeenCalledWith({
99+
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
100+
})
101+
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
102+
expect(onChange).toHaveBeenCalledTimes(1)
103+
})
104+
105+
it('cancels the previous debounce when typing continues', () => {
106+
const onChange = vi.fn()
107+
renderFilter(onChange)
108+
const input = container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')
109+
const setInput = (value: string) => {
110+
if (!input) return
111+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
112+
input.dispatchEvent(new Event('input', { bubbles: true }))
113+
}
114+
115+
act(() => setInput('Ada'))
116+
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1))
117+
act(() => setInput('Grace'))
118+
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
119+
120+
expect(onChange).toHaveBeenCalledTimes(1)
121+
expect(onChange).toHaveBeenCalledWith({
122+
all: [{ field: 'col-name', op: 'eq', value: 'Grace' }],
123+
})
124+
})
125+
126+
it('clears the active filter when its last rule is removed', () => {
127+
const onChange = vi.fn()
128+
renderFilter(onChange, {
129+
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
130+
})
131+
132+
const removeButton = container.querySelector<HTMLButtonElement>(
133+
'button[aria-label="Remove filter"]'
134+
)
135+
act(() => removeButton?.click())
136+
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
137+
138+
expect(onChange).toHaveBeenCalledWith(null)
139+
expect(
140+
container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')?.value
141+
).toBe('')
142+
})
143+
144+
it('normalizes a previously saved OR filter to AND', () => {
145+
const onChange = vi.fn()
146+
renderFilter(onChange, {
147+
any: [
148+
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
149+
{ all: [{ field: 'col-name', op: 'eq', value: 'Grace' }] },
150+
],
151+
})
152+
153+
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
154+
155+
expect(onChange).toHaveBeenCalledWith({
156+
all: [
157+
{ field: 'col-name', op: 'eq', value: 'Ada' },
158+
{ field: 'col-name', op: 'eq', value: 'Grace' },
159+
],
160+
})
161+
})
162+
})

0 commit comments

Comments
 (0)