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
@@ -0,0 +1,168 @@
/**
* @vitest-environment jsdom
*/
import { act } 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 { TableFilter } 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
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

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

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

function valueInput(): HTMLInputElement | null {
return container.querySelector<HTMLInputElement>('input[placeholder="Enter a value"]')
}

function typeInto(input: HTMLInputElement | null, value: string) {
if (!input) return
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}

describe('TableFilter', () => {
it('commits a typed value on blur, not per keystroke', () => {
const onChange = vi.fn()
renderFilter(onChange)
const input = valueInput()
expect(input).not.toBeNull()

act(() => typeInto(input, 'Ada'))
expect(onChange).not.toHaveBeenCalled()

act(() => input?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith({
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
})
})

it('commits a typed value on Enter', () => {
const onChange = vi.fn()
renderFilter(onChange)
const input = valueInput()

act(() => typeInto(input, 'Grace'))
act(() => input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))

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

it('does not re-commit an unchanged value on blur after Enter', () => {
const onChange = vi.fn()
renderFilter(onChange)
const input = valueInput()

act(() => typeInto(input, 'Ada'))
act(() => input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
act(() => input?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))

expect(onChange).toHaveBeenCalledTimes(1)
})

it('offers a toggleable conjunction 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('button')).find(
(button) => button.textContent?.trim() === 'and'
)
expect(conjunction).toBeDefined()

act(() => conjunction?.click())
expect(conjunction?.textContent?.trim()).toBe('or')

expect(container.textContent).not.toContain('Apply filter')
expect(container.textContent).not.toContain('Clear filters')
})

it('clears the active filter as soon as 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())

expect(onChange).toHaveBeenCalledWith(null)
expect(valueInput()?.value).toBe('')
})

it('preserves saved isNull conditions instead of dropping them', () => {
const onChange = vi.fn()
renderFilter(onChange, { all: [{ field: 'col-name', op: 'isNull' }] })

expect(onChange).not.toHaveBeenCalled()
})

it('loads a saved OR filter verbatim without an unsolicited autosave', () => {
const onChange = vi.fn()
renderFilter(onChange, {
any: [
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
{ all: [{ field: 'col-name', op: 'eq', value: 'Grace' }] },
],
})

const orToggle = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'or'
)
expect(orToggle).toBeDefined()
expect(onChange).not.toHaveBeenCalled()
})

it('merges the OR groups as soon as the conjunction is toggled back 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' }] },
],
})

const orToggle = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'or'
)
act(() => orToggle?.click())

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