Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,35 @@ describe('SortDropdown', () => {
expect(item?.querySelector('[data-testid="column-icon"]')).not.toBeNull()
expect(item?.querySelectorAll('svg')).toHaveLength(2)
})

it('keeps the popup open while changing or clearing the sort', () => {
const onOpenChange = vi.fn()
const onSort = vi.fn()
const onClear = vi.fn()
act(() => {
root.render(
<SortDropdown
open
onOpenChange={onOpenChange}
config={{
options: [{ id: 'name', label: 'Name', icon: ColumnIcon }],
active: { column: 'name', direction: 'asc' },
onSort,
onClear,
}}
/>
)
})

const items = document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')
expect(items).toHaveLength(2)

act(() => items[1]?.click())
expect(onSort).toHaveBeenCalledWith('name', 'desc')

act(() => items[0]?.click())
expect(onClear).toHaveBeenCalledOnce()
expect(onOpenChange).not.toHaveBeenCalledWith(false)
expect(document.body.querySelectorAll('[role="menuitem"]')).toHaveLength(2)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,12 @@ export const SortDropdown = memo(function SortDropdown({
>
{active && onClear && (
<>
<DropdownMenuItem onSelect={onClear}>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
onClear()
}}
>
<X />
Clear sort
</DropdownMenuItem>
Expand All @@ -305,7 +310,8 @@ export const SortDropdown = memo(function SortDropdown({
return (
<DropdownMenuItem
key={option.id}
onSelect={() => {
onSelect={(event) => {
event.preventDefault()
if (isActive) {
onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc')
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @vitest-environment jsdom
*/
import { act, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ColumnsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu'

let container: HTMLDivElement
let root: Root

function ColumnsMenuHarness({ onChange }: { onChange: (hiddenColumns: string[]) => void }) {
const [hiddenColumns, setHiddenColumns] = useState<string[]>([])

return (
<ColumnsMenu
columns={[
{ id: 'col-name', name: 'Name', type: 'string' },
{ id: 'col-email', name: 'Email', type: 'string' },
{ id: 'col-company', name: 'Company', type: 'string' },
]}
workflowGroups={[]}
hiddenColumns={hiddenColumns}
onChange={(nextHiddenColumns) => {
setHiddenColumns(nextHiddenColumns)
onChange(nextHiddenColumns)
}}
/>
)
}

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

describe('ColumnsMenu', () => {
it('uses the app menu styling and stays open across column changes', () => {
const onChange = vi.fn()
act(() => {
root.render(<ColumnsMenuHarness onChange={onChange} />)
})
act(() => {
container
.querySelector<HTMLButtonElement>('button')
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
})

const items = document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')
expect(items).toHaveLength(3)
expect(items[0]).toHaveClass('text-small')
expect(items[0]?.querySelector('svg')).toHaveClass('size-[14px]')

act(() => items[0]?.click())
expect(onChange).toHaveBeenCalledWith(['col-name'])

const remainingItems = document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')
expect(remainingItems).toHaveLength(3)
act(() => remainingItems[1]?.click())
expect(onChange).toHaveBeenLastCalledWith(['col-name', 'col-email'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import { memo, useMemo, useState } from 'react'
import {
Chip,
cn,
POPOVER_ANIMATION_CLASSES,
Popover,
PopoverContent,
PopoverItem,
PopoverSection,
PopoverTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Columns3, Eye, EyeOff } from '@sim/emcn/icons'
import type { ColumnDefinition, WorkflowGroup } from '@/lib/table'
Expand Down Expand Up @@ -78,30 +76,18 @@ export const ColumnsMenu = memo(function ColumnsMenu({
const hiddenCount = hiddenColumns.length

return (
<Popover size='md' open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
{/* `active` alone signals that something is hidden — the label stays fixed
so the bar doesn't reflow as columns are toggled. */}
<Chip active={hiddenCount > 0} leftIcon={Columns3}>
Columns
</Chip>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='start'
sideOffset={6}
minWidth={240}
maxWidth={320}
maxHeight={420}
border
className={cn(
POPOVER_ANIMATION_CLASSES,
'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
>
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
Columns
</PopoverSection>
<div className='flex flex-col gap-0.5'>
{plain.map((col) => {
const id = getColumnId(col)
Expand Down Expand Up @@ -144,8 +130,8 @@ export const ColumnsMenu = memo(function ColumnsMenu({
)
})}
</div>
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
)
})

Expand All @@ -164,14 +150,17 @@ function ColumnToggleRow({ label, visible, partial, indented, onToggle }: Column
const showing = visible || partial
const Icon = showing ? Eye : EyeOff
return (
<PopoverItem
onClick={() => onToggle(!visible)}
className={cn('h-7 items-center gap-1.5 px-1.5 py-0 text-xs', indented && 'pl-5')}
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
onToggle(!visible)
}}
className={cn(indented && 'pl-7')}
>
<span className='flex size-[14px] shrink-0 items-center justify-center'>
<Icon
className={cn(
'size-3',
'size-[14px]',
showing ? 'text-[var(--text-icon)]' : 'text-[var(--text-muted)]',
partial && 'opacity-60'
)}
Expand All @@ -182,6 +171,6 @@ function ColumnToggleRow({ label, visible, partial, indented, onToggle }: Column
>
{label}
</span>
</PopoverItem>
</DropdownMenuItem>
)
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { TableFilter } from './table-filter'
export { TableFilter, type TableFilterHandle } from './table-filter'
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* @vitest-environment jsdom
*/
import { act, createRef, type Ref } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ColumnDefinition, TablePredicate } from '@/lib/table'
import {
FILTER_DEBOUNCE_MS,
TableFilter,
type TableFilterHandle,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter'

const COLUMNS: ColumnDefinition[] = [{ id: 'col-name', name: 'Name', type: 'string' }]

let container: HTMLDivElement
let root: Root

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
vi.useRealTimers()
})

function renderFilter(
onChange: (filter: TablePredicate | null) => void,
filter: TablePredicate | null = null,
ref?: Ref<TableFilterHandle>
) {
act(() => {
root.render(<TableFilter ref={ref} columns={COLUMNS} filter={filter} onChange={onChange} />)
})
}

describe('TableFilter', () => {
it('applies text filters after a short typing delay', () => {
const onApply = vi.fn()
renderFilter(onApply)
const input = container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')
expect(input).not.toBeNull()

act(() => {
if (!input) return
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada')
input.dispatchEvent(new Event('input', { bubbles: true }))
})

expect(onApply).not.toHaveBeenCalled()
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1))
expect(onApply).not.toHaveBeenCalled()
act(() => vi.advanceTimersByTime(1))
expect(onApply).toHaveBeenCalledWith({
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
})
})

it('uses fixed AND conjunctions without apply or clear actions', () => {
renderFilter(vi.fn())
const addFilter = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Add filter')
)

act(() => addFilter?.click())

const conjunction = Array.from(container.querySelectorAll('*')).find(
(element) => element.textContent?.trim() === 'and'
)
expect(conjunction).toBeDefined()
expect(conjunction?.closest('button')).toBeNull()
expect(container.textContent).not.toContain('Apply filter')
expect(container.textContent).not.toContain('Clear filters')
})

it('flushes the pending filter when the panel closes before the delay', () => {
const onChange = vi.fn()
const filterRef = createRef<TableFilterHandle>()
renderFilter(onChange, null, filterRef)
const input = container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')

act(() => {
if (!input) return
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
act(() => {
filterRef.current?.flush()
})

expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith({
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
})
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
expect(onChange).toHaveBeenCalledTimes(1)
})

it('cancels the previous debounce when typing continues', () => {
const onChange = vi.fn()
renderFilter(onChange)
const input = container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')
const setInput = (value: string) => {
if (!input) return
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}

act(() => setInput('Ada'))
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1))
act(() => setInput('Grace'))
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))

expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith({
all: [{ field: 'col-name', op: 'eq', value: 'Grace' }],
})
})

it('clears the active filter when its last rule is removed', () => {
const onChange = vi.fn()
renderFilter(onChange, {
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
})

const removeButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Remove filter"]'
)
act(() => removeButton?.click())
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))

expect(onChange).toHaveBeenCalledWith(null)
expect(
container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')?.value
).toBe('')
})

it('normalizes a previously saved OR filter to AND', () => {
const onChange = vi.fn()
renderFilter(onChange, {
any: [
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
{ all: [{ field: 'col-name', op: 'eq', value: 'Grace' }] },
],
})

act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))

expect(onChange).toHaveBeenCalledWith({
all: [
{ field: 'col-name', op: 'eq', value: 'Ada' },
{ field: 'col-name', op: 'eq', value: 'Grace' },
],
})
})
})
Loading
Loading