mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(tables): improve view and filter controls (#6725)
* feat(tables): improve view and filter controls * fix(tables): keep menu actions open * fix(tables): guard view autosave against echo remounts and stale responses Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tables): keep and/or filter toggles, autosave only real edits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tables): compute view-row action spacer and cover the default pin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tables): apply filter text on enter or blur instead of a debounce Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tables): apply filter edits from user events * fix(tables): reject stale default promotions * fix(tables): preserve filters during rule transitions * fix(tables): isolate flagged view interactions * fix(tables): preserve OR boundaries when rules drop * fix(tables): retain deferred filter conditions * fix(tables): keep hidden view actions click-through * fix(tables): keep disabled view pins from selecting the row A disabled Button is pointer-events-none, so clicks on the default or read-only pin fell through the overlay to the row and selected the view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
38630ff46d
commit
85902eb7ff
+56
@@ -67,4 +67,60 @@ 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,
|
||||
keepOpenOnSelect: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('keeps the legacy close-on-select behavior by default', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onSort = vi.fn()
|
||||
act(() => {
|
||||
root.render(
|
||||
<SortDropdown
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
config={{
|
||||
options: [{ id: 'name', label: 'Name', icon: ColumnIcon }],
|
||||
active: null,
|
||||
onSort,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const item = document.body.querySelector<HTMLElement>('[role="menuitem"]')
|
||||
act(() => item?.click())
|
||||
|
||||
expect(onSort).toHaveBeenCalledWith('name', 'desc')
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
+10
-3
@@ -48,6 +48,7 @@ export interface SortConfig {
|
||||
active: { column: string; direction: SortDirection } | null
|
||||
onSort: (column: string, direction: SortDirection) => void
|
||||
onClear?: () => void
|
||||
keepOpenOnSelect?: boolean
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
@@ -283,7 +284,7 @@ export const SortDropdown = memo(function SortDropdown({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SortDropdownProps) {
|
||||
const { options, active, onSort, onClear } = config
|
||||
const { options, active, onSort, onClear, keepOpenOnSelect = false } = config
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
@@ -299,7 +300,12 @@ export const SortDropdown = memo(function SortDropdown({
|
||||
>
|
||||
{active && onClear && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onClear}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
if (keepOpenOnSelect) event.preventDefault()
|
||||
onClear()
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
Clear sort
|
||||
</DropdownMenuItem>
|
||||
@@ -314,7 +320,8 @@ export const SortDropdown = memo(function SortDropdown({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => {
|
||||
onSelect={(event) => {
|
||||
if (keepOpenOnSelect) event.preventDefault()
|
||||
if (isActive) {
|
||||
onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
|
||||
+69
@@ -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'])
|
||||
})
|
||||
})
|
||||
+20
-31
@@ -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'
|
||||
@@ -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)
|
||||
@@ -144,8 +130,8 @@ export const ColumnsMenu = memo(function ColumnsMenu({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
@@ -182,6 +171,6 @@ function ColumnToggleRow({ label, visible, partial, indented, onToggle }: Column
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</PopoverItem>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* @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,
|
||||
autoApply = true,
|
||||
onClose: () => void = vi.fn()
|
||||
) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TableFilter
|
||||
columns={COLUMNS}
|
||||
filter={filter}
|
||||
autoApply={autoApply}
|
||||
onChange={onChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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('keeps the legacy Apply flow while automatic view saves are disabled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderFilter(onChange, null, false)
|
||||
const input = valueInput()
|
||||
|
||||
act(() => typeInto(input, 'Ada'))
|
||||
act(() => input?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
const applyButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Apply filter'
|
||||
)
|
||||
act(() => applyButton?.click())
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
|
||||
})
|
||||
})
|
||||
|
||||
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('keeps a saved valueless filter until its replacement value is committed', () => {
|
||||
const onChange = vi.fn()
|
||||
renderFilter(onChange, { all: [{ field: 'col-name', op: 'isEmpty' }] })
|
||||
|
||||
const operatorTrigger = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'is empty'
|
||||
)
|
||||
act(() => {
|
||||
operatorTrigger?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
|
||||
})
|
||||
const equalsOption = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent?.trim() === 'equals')
|
||||
act(() => equalsOption?.click())
|
||||
|
||||
expect(valueInput()).not.toBeNull()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
act(() => typeInto(valueInput(), 'Ada'))
|
||||
act(() => valueInput()?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a deferred condition applied while another rule is removed', () => {
|
||||
const onChange = vi.fn()
|
||||
renderFilter(onChange, {
|
||||
any: [
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
|
||||
{
|
||||
all: [
|
||||
{ field: 'col-name', op: 'isEmpty' },
|
||||
{ field: 'col-name', op: 'eq', value: 'Linus' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const operatorTrigger = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'is empty'
|
||||
)
|
||||
act(() => {
|
||||
operatorTrigger?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
|
||||
})
|
||||
const equalsOption = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent?.trim() === 'equals')
|
||||
act(() => equalsOption?.click())
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Remove filter"]'
|
||||
)
|
||||
act(() => removeButtons[2]?.click())
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
any: [
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
|
||||
{ all: [{ field: 'col-name', op: 'isEmpty' }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
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('does not autosave when columns refresh without a user edit', () => {
|
||||
const onChange = vi.fn()
|
||||
act(() => {
|
||||
root.render(<TableFilter columns={COLUMNS} filter={null} autoApply onChange={onChange} />)
|
||||
})
|
||||
act(() => {
|
||||
root.render(
|
||||
<TableFilter columns={[...COLUMNS]} filter={null} autoApply onChange={onChange} />
|
||||
)
|
||||
})
|
||||
|
||||
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' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an OR boundary when its first rule is cleared', () => {
|
||||
const onChange = vi.fn()
|
||||
renderFilter(onChange, {
|
||||
any: [
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
|
||||
{
|
||||
all: [
|
||||
{ field: 'col-name', op: 'eq', value: 'Grace' },
|
||||
{ field: 'col-name', op: 'eq', value: 'Linus' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const inputs = container.querySelectorAll<HTMLInputElement>(
|
||||
'input[placeholder="Enter a value"]'
|
||||
)
|
||||
act(() => typeInto(inputs[1], ''))
|
||||
act(() => inputs[1]?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
any: [
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Linus' }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an OR boundary when its first rule is removed', () => {
|
||||
const onChange = vi.fn()
|
||||
renderFilter(onChange, {
|
||||
any: [
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
|
||||
{
|
||||
all: [
|
||||
{ field: 'col-name', op: 'eq', value: 'Grace' },
|
||||
{ field: 'col-name', op: 'eq', value: 'Linus' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Remove filter"]'
|
||||
)
|
||||
act(() => removeButtons[1]?.click())
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
any: [
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
|
||||
{ all: [{ field: 'col-name', op: 'eq', value: 'Linus' }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
+218
-71
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Button, ChipDropdown, ChipInput } from '@sim/emcn'
|
||||
import { Button, ChipDropdown, ChipInput, cn } from '@sim/emcn'
|
||||
import { Plus, X } from '@sim/emcn/icons'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import type { ColumnDefinition, FilterRule, TablePredicate } from '@/lib/table'
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
COMPARISON_OPERATORS,
|
||||
MULTI_SELECT_FILTER_OPERATORS,
|
||||
SINGLE_SELECT_FILTER_OPERATORS,
|
||||
VALUELESS_OPERATORS,
|
||||
} from '@/lib/table/query-builder/constants'
|
||||
import {
|
||||
filterRulesToPredicate,
|
||||
predicateToFilterRules,
|
||||
VALUELESS_OPS,
|
||||
} from '@/lib/table/query-builder/converters'
|
||||
|
||||
const SINGLE_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) =>
|
||||
@@ -28,21 +28,94 @@ function selectFilterOperators(column: ColumnDefinition | undefined): Set<string
|
||||
return column?.multiple ? MULTI_SELECT_FILTER_OPERATORS : SINGLE_SELECT_FILTER_OPERATORS
|
||||
}
|
||||
|
||||
function toAppliedPredicate(
|
||||
rules: FilterRule[],
|
||||
columns: ColumnDefinition[],
|
||||
preserveIncompleteBoundaries = false
|
||||
): TablePredicate | null {
|
||||
const validRules = preserveIncompleteBoundaries
|
||||
? rules.map((rule) => (isCompleteRule(rule) ? rule : { ...rule, column: '' }))
|
||||
: rules.filter(isCompleteRule)
|
||||
return filterRulesToPredicate(validRules, columns)
|
||||
}
|
||||
|
||||
function isCompleteRule(rule: FilterRule): boolean {
|
||||
return Boolean(rule.column && (rule.value || VALUELESS_OPS.has(rule.operator)))
|
||||
}
|
||||
|
||||
interface TableFilterProps {
|
||||
columns: ColumnDefinition[]
|
||||
filter: TablePredicate | null
|
||||
onApply: (filter: TablePredicate | null) => void
|
||||
onClose: () => void
|
||||
autoApply?: boolean
|
||||
onChange: (filter: TablePredicate | null) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) {
|
||||
export function TableFilter({
|
||||
columns,
|
||||
filter,
|
||||
autoApply = false,
|
||||
onChange,
|
||||
onClose,
|
||||
}: TableFilterProps) {
|
||||
const lastAppliedFilterRef = useRef<string | undefined>(undefined)
|
||||
const deferredAppliedRulesRef = useRef<Map<string, FilterRule>>(new Map())
|
||||
const [rules, setRules] = useState<FilterRule[]>(() => {
|
||||
const fromFilter = predicateToFilterRules(filter)
|
||||
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
|
||||
})
|
||||
|
||||
const rulesRef = useRef(rules)
|
||||
rulesRef.current = rules
|
||||
// Seed the "already applied" signature from the rules the panel actually
|
||||
// renders, not the raw prop: a saved tree the flat builder cannot express
|
||||
// (deeply nested groups, wire key order) round-trips differently, and seeding
|
||||
// from the prop would fire an unedited autosave of that lossy form the
|
||||
// moment the panel opens. The normalized form persists only once the user
|
||||
// really edits a rule.
|
||||
lastAppliedFilterRef.current ??= JSON.stringify(toAppliedPredicate(rules, columns))
|
||||
|
||||
const applyRules = useCallback(
|
||||
(update: (current: FilterRule[]) => FilterRule[], deferIncompleteRuleId?: string) => {
|
||||
const currentRules = rulesRef.current
|
||||
const nextRules = update(currentRules)
|
||||
rulesRef.current = nextRules
|
||||
setRules(nextRules)
|
||||
if (!autoApply) return
|
||||
|
||||
const deferredRule = nextRules.find((rule) => rule.id === deferIncompleteRuleId)
|
||||
if (deferredRule && !isCompleteRule(deferredRule)) {
|
||||
const previouslyAppliedRule = currentRules.find((rule) => rule.id === deferredRule.id)
|
||||
if (previouslyAppliedRule && isCompleteRule(previouslyAppliedRule)) {
|
||||
const deferredRules = deferredAppliedRulesRef.current
|
||||
if (!deferredRules.has(deferredRule.id)) {
|
||||
deferredRules.set(deferredRule.id, previouslyAppliedRule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextRulesById = new Map(nextRules.map((rule) => [rule.id, rule]))
|
||||
for (const [id] of deferredAppliedRulesRef.current) {
|
||||
const nextRule = nextRulesById.get(id)
|
||||
if (!nextRule || isCompleteRule(nextRule)) {
|
||||
deferredAppliedRulesRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const appliedRules = nextRules.map((rule) => {
|
||||
const deferredRule = deferredAppliedRulesRef.current.get(rule.id)
|
||||
return deferredRule && !isCompleteRule(rule)
|
||||
? { ...deferredRule, logicalOperator: rule.logicalOperator }
|
||||
: rule
|
||||
})
|
||||
|
||||
const nextFilter = toAppliedPredicate(appliedRules, columns, true)
|
||||
const signature = JSON.stringify(nextFilter)
|
||||
if (signature === lastAppliedFilterRef.current) return
|
||||
lastAppliedFilterRef.current = signature
|
||||
onChange(nextFilter)
|
||||
},
|
||||
[autoApply, columns, onChange]
|
||||
)
|
||||
|
||||
// `value` is the filter field key (column id); `label` is what the user sees.
|
||||
const columnOptions = useMemo(
|
||||
@@ -56,26 +129,60 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr
|
||||
)
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setRules((prev) => [...prev, createRule(columns)])
|
||||
}, [columns])
|
||||
applyRules((current) => [...current, createRule(columns)])
|
||||
}, [applyRules, columns])
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
const next = rulesRef.current.filter((r) => r.id !== id)
|
||||
if (next.length === 0) {
|
||||
onApply(null)
|
||||
onClose()
|
||||
setRules([createRule(columns)])
|
||||
} else {
|
||||
setRules(next)
|
||||
if (!autoApply) {
|
||||
const nextRules = rulesRef.current.filter((rule) => rule.id !== id)
|
||||
if (nextRules.length > 0) {
|
||||
rulesRef.current = nextRules
|
||||
setRules(nextRules)
|
||||
return
|
||||
}
|
||||
const resetRules = [createRule(columns)]
|
||||
rulesRef.current = resetRules
|
||||
setRules(resetRules)
|
||||
onChange(null)
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
applyRules((current) => {
|
||||
const removedIndex = current.findIndex((rule) => rule.id === id)
|
||||
const removedRule = current[removedIndex]
|
||||
const next = current.filter((rule) => rule.id !== id)
|
||||
if (removedRule?.logicalOperator === 'or' && removedIndex < next.length) {
|
||||
next[removedIndex] = { ...next[removedIndex], logicalOperator: 'or' }
|
||||
}
|
||||
return next.length > 0 ? next : [createRule(columns)]
|
||||
})
|
||||
},
|
||||
[columns, onApply, onClose]
|
||||
[applyRules, autoApply, columns, onChange, onClose]
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => {
|
||||
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
|
||||
}, [])
|
||||
const handleUpdate = useCallback(
|
||||
(id: string, field: keyof FilterRule, value: string) => {
|
||||
applyRules(
|
||||
(current) => current.map((rule) => (rule.id === id ? { ...rule, [field]: value } : rule)),
|
||||
field === 'operator' ? id : undefined
|
||||
)
|
||||
},
|
||||
[applyRules]
|
||||
)
|
||||
|
||||
const handleToggleLogical = useCallback(
|
||||
(id: string) => {
|
||||
applyRules((current) =>
|
||||
current.map((rule) =>
|
||||
rule.id === id
|
||||
? { ...rule, logicalOperator: rule.logicalOperator === 'and' ? 'or' : 'and' }
|
||||
: rule
|
||||
)
|
||||
)
|
||||
},
|
||||
[applyRules]
|
||||
)
|
||||
|
||||
// Switching a rule's column across the select boundary changes what values and
|
||||
// operators are valid, so clear the value and coerce an unsupported operator
|
||||
@@ -83,45 +190,38 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr
|
||||
// apply against a select column and be rejected server-side.
|
||||
const handleColumnChange = useCallback(
|
||||
(id: string, columnId: string) => {
|
||||
setRules((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.id !== id) return r
|
||||
const previous = columnById.get(r.column)
|
||||
const next = columnById.get(columnId)
|
||||
const wasSelect = previous?.type === 'select'
|
||||
const isSelect = next?.type === 'select'
|
||||
if (!wasSelect && !isSelect) return { ...r, column: columnId }
|
||||
// Single- and multi-select take different operators, so a switch
|
||||
// between them has to fall back too, not just select ↔ non-select.
|
||||
const allowed = selectFilterOperators(next)
|
||||
const fallback = next?.multiple ? 'contains' : 'eq'
|
||||
const operator = isSelect && !allowed.has(r.operator) ? fallback : r.operator
|
||||
return { ...r, column: columnId, operator, value: '' }
|
||||
})
|
||||
applyRules(
|
||||
(current) =>
|
||||
current.map((rule) => {
|
||||
if (rule.id !== id) return rule
|
||||
const previous = columnById.get(rule.column)
|
||||
const next = columnById.get(columnId)
|
||||
const wasSelect = previous?.type === 'select'
|
||||
const isSelect = next?.type === 'select'
|
||||
if (!wasSelect && !isSelect) return { ...rule, column: columnId }
|
||||
// Single- and multi-select take different operators, so a switch
|
||||
// between them has to fall back too, not just select ↔ non-select.
|
||||
const allowed = selectFilterOperators(next)
|
||||
const fallback = next?.multiple ? 'contains' : 'eq'
|
||||
const operator = isSelect && !allowed.has(rule.operator) ? fallback : rule.operator
|
||||
return { ...rule, column: columnId, operator, value: '' }
|
||||
}),
|
||||
id
|
||||
)
|
||||
},
|
||||
[columnById]
|
||||
[applyRules, columnById]
|
||||
)
|
||||
|
||||
const handleToggleLogical = useCallback((id: string) => {
|
||||
setRules((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r
|
||||
)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const validRules = rulesRef.current.filter(
|
||||
(r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator))
|
||||
)
|
||||
onApply(filterRulesToPredicate(validRules, columns))
|
||||
}, [columns, onApply])
|
||||
onChange(toAppliedPredicate(rulesRef.current, columns))
|
||||
}, [columns, onChange])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setRules([createRule(columns)])
|
||||
onApply(null)
|
||||
}, [columns, onApply])
|
||||
const handleClear = () => {
|
||||
const resetRules = [createRule(columns)]
|
||||
rulesRef.current = resetRules
|
||||
setRules(resetRules)
|
||||
onChange(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='border-[var(--border)] border-b bg-[var(--bg)] px-4 py-2'>
|
||||
@@ -136,12 +236,13 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr
|
||||
onUpdate={handleUpdate}
|
||||
onColumnChange={handleColumnChange}
|
||||
onRemove={handleRemove}
|
||||
autoApply={autoApply}
|
||||
onApply={handleApply}
|
||||
onToggleLogical={handleToggleLogical}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className='mt-1 flex items-center justify-between'>
|
||||
<div className={cn('mt-1 flex items-center', !autoApply && 'justify-between')}>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
@@ -151,21 +252,23 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr
|
||||
<Plus className='mr-1 size-[10px]' />
|
||||
Add filter
|
||||
</Button>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{filter !== null && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleClear}
|
||||
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
|
||||
>
|
||||
Clear filters
|
||||
{!autoApply && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{filter !== null && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleClear}
|
||||
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
|
||||
Apply filter
|
||||
</Button>
|
||||
)}
|
||||
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
|
||||
Apply filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,6 +283,7 @@ interface FilterRuleRowProps {
|
||||
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
|
||||
onColumnChange: (id: string, columnId: string) => void
|
||||
onRemove: (id: string) => void
|
||||
autoApply: boolean
|
||||
onApply: () => void
|
||||
onToggleLogical: (id: string) => void
|
||||
}
|
||||
@@ -192,6 +296,7 @@ const FilterRuleRow = memo(function FilterRuleRow({
|
||||
onUpdate,
|
||||
onColumnChange,
|
||||
onRemove,
|
||||
autoApply,
|
||||
onApply,
|
||||
onToggleLogical,
|
||||
}: FilterRuleRowProps) {
|
||||
@@ -254,7 +359,7 @@ const FilterRuleRow = memo(function FilterRuleRow({
|
||||
className='min-w-[90px]'
|
||||
/>
|
||||
|
||||
{VALUELESS_OPERATORS.has(rule.operator) ? (
|
||||
{VALUELESS_OPS.has(rule.operator) ? (
|
||||
<div className='h-[30px] flex-1' />
|
||||
) : isSelect ? (
|
||||
<ChipDropdown
|
||||
@@ -266,12 +371,17 @@ const FilterRuleRow = memo(function FilterRuleRow({
|
||||
matchTriggerWidth={false}
|
||||
className='min-w-[100px] flex-1'
|
||||
/>
|
||||
) : autoApply ? (
|
||||
<FilterValueInput
|
||||
value={rule.value}
|
||||
onCommit={(value) => onUpdate(rule.id, 'value', value)}
|
||||
/>
|
||||
) : (
|
||||
<ChipInput
|
||||
value={rule.value}
|
||||
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onApply()
|
||||
onChange={(event) => onUpdate(rule.id, 'value', event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') onApply()
|
||||
}}
|
||||
placeholder='Enter a value'
|
||||
className='flex-1'
|
||||
@@ -291,6 +401,43 @@ const FilterRuleRow = memo(function FilterRuleRow({
|
||||
)
|
||||
})
|
||||
|
||||
interface FilterValueInputProps {
|
||||
value: string
|
||||
onCommit: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally buffered value field: keystrokes stay in the field until Enter or
|
||||
* blur commits them — the spreadsheet-cell contract. Every click-driven exit
|
||||
* from the panel (closing it, switching views, navigating away) blurs the
|
||||
* field first, so finishing-by-leaving commits without any imperative
|
||||
* coordination. An external reseed (column switch clearing the value, a view
|
||||
* replacement remount) adopts the incoming value over the draft.
|
||||
*/
|
||||
function FilterValueInput({ value, onCommit }: FilterValueInputProps) {
|
||||
const [draft, setDraft] = useState(value)
|
||||
const [prevValue, setPrevValue] = useState(value)
|
||||
if (prevValue !== value) {
|
||||
setPrevValue(value)
|
||||
setDraft(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipInput
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (draft !== value) onCommit(draft)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && draft !== value) onCommit(draft)
|
||||
}}
|
||||
placeholder='Enter a value'
|
||||
className='flex-1'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function createRule(columns: ColumnDefinition[]): FilterRule {
|
||||
const first = columns[0]
|
||||
return {
|
||||
|
||||
+126
-7
@@ -4,7 +4,7 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TableViewWire } from '@/lib/api/contracts/tables'
|
||||
import { ViewsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu'
|
||||
|
||||
@@ -19,13 +19,22 @@ const DEFAULT_VIEW: TableViewWire = {
|
||||
updatedAt: new Date('2026-08-15T01:00:00.000Z'),
|
||||
}
|
||||
|
||||
const SAVED_VIEW: TableViewWire = {
|
||||
const SECOND_VIEW: TableViewWire = {
|
||||
...DEFAULT_VIEW,
|
||||
id: 'view-saved',
|
||||
name: 'Saved',
|
||||
id: 'view-second',
|
||||
name: 'Second view',
|
||||
isDefault: false,
|
||||
}
|
||||
|
||||
const PRIMARY_VIEW: TableViewWire = {
|
||||
...DEFAULT_VIEW,
|
||||
name: 'Primary view',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderMenu(views: TableViewWire[], activeViewId: string | null): string {
|
||||
return renderToStaticMarkup(
|
||||
<ViewsMenu
|
||||
@@ -33,6 +42,7 @@ function renderMenu(views: TableViewWire[], activeViewId: string | null): string
|
||||
activeViewId={activeViewId}
|
||||
onSelect={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onSetDefault={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onNewView={vi.fn()}
|
||||
canEdit
|
||||
@@ -55,18 +65,20 @@ describe('ViewsMenu', () => {
|
||||
expect(markup).not.toContain('>View<')
|
||||
})
|
||||
|
||||
it('only offers deletion for non-default views', () => {
|
||||
it('shows filled and outline pins without a Default badge and keeps the menu open', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onSetDefault = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ViewsMenu
|
||||
views={[DEFAULT_VIEW, SAVED_VIEW]}
|
||||
activeViewId={DEFAULT_VIEW.id}
|
||||
views={[PRIMARY_VIEW, SECOND_VIEW]}
|
||||
activeViewId={PRIMARY_VIEW.id}
|
||||
onSelect={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onSetDefault={onSetDefault}
|
||||
onDelete={vi.fn()}
|
||||
onNewView={vi.fn()}
|
||||
canEdit
|
||||
@@ -76,6 +88,113 @@ describe('ViewsMenu', () => {
|
||||
act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Views"]')?.click())
|
||||
|
||||
expect(document.body.querySelectorAll('button[aria-label="Delete"]')).toHaveLength(1)
|
||||
const defaultPin = document.body.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Current default view"]'
|
||||
)
|
||||
const setDefaultPin = document.body.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Set as default"]'
|
||||
)
|
||||
|
||||
expect(defaultPin?.querySelector('svg')).toHaveClass('fill-current')
|
||||
expect(setDefaultPin?.querySelector('svg')).not.toHaveClass('fill-current')
|
||||
expect(document.body).not.toHaveTextContent('Default')
|
||||
|
||||
act(() => setDefaultPin?.click())
|
||||
expect(onSetDefault).toHaveBeenCalledWith(SECOND_VIEW.id)
|
||||
expect(document.body).toHaveTextContent('New view')
|
||||
|
||||
expect(defaultPin).toBeDisabled()
|
||||
act(() => defaultPin?.click())
|
||||
expect(onSetDefault).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('keeps the menu open when keyboard focus moves from the trigger to the default pin', () => {
|
||||
vi.useFakeTimers()
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ViewsMenu
|
||||
views={[PRIMARY_VIEW, SECOND_VIEW]}
|
||||
activeViewId={PRIMARY_VIEW.id}
|
||||
onSelect={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onSetDefault={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onNewView={vi.fn()}
|
||||
canEdit
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const trigger = container.querySelector<HTMLButtonElement>('button[aria-label="Views"]')
|
||||
act(() => trigger?.focus())
|
||||
|
||||
const setDefaultPin = document.body.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Set as default"]'
|
||||
)
|
||||
expect(setDefaultPin).not.toBeNull()
|
||||
act(() => {
|
||||
setDefaultPin?.focus()
|
||||
vi.advanceTimersByTime(121)
|
||||
})
|
||||
|
||||
expect(document.activeElement).toBe(setDefaultPin)
|
||||
expect(document.body).toHaveTextContent('New view')
|
||||
expect(document.body.querySelector('[data-native-surface-overlay]')).not.toBeNull()
|
||||
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('shows disabled pins without closing the menu for read-only members', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onSetDefault = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ViewsMenu
|
||||
views={[PRIMARY_VIEW, SECOND_VIEW]}
|
||||
activeViewId={PRIMARY_VIEW.id}
|
||||
onSelect={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onSetDefault={onSetDefault}
|
||||
onDelete={vi.fn()}
|
||||
onNewView={vi.fn()}
|
||||
canEdit={false}
|
||||
/>
|
||||
)
|
||||
})
|
||||
act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Views"]')?.click())
|
||||
|
||||
const defaultPin = document.body.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Current default view"]'
|
||||
)
|
||||
const setDefaultPin = document.body.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Set as default"]'
|
||||
)
|
||||
|
||||
expect(defaultPin?.querySelector('svg')).toHaveClass('fill-current')
|
||||
expect(setDefaultPin?.querySelector('svg')).not.toHaveClass('fill-current')
|
||||
expect(setDefaultPin).toBeDisabled()
|
||||
|
||||
act(() => setDefaultPin?.click())
|
||||
|
||||
expect(onSetDefault).not.toHaveBeenCalled()
|
||||
expect(document.body.querySelector('[data-native-surface-overlay]')).not.toBeNull()
|
||||
|
||||
// jsdom does not simulate pointer-events hit-testing, so assert the override
|
||||
// directly: without it a disabled pin is pointer-events-none and a real click
|
||||
// falls through the overlay to the row, selecting the view and closing the menu.
|
||||
expect(defaultPin).toHaveClass('disabled:pointer-events-auto')
|
||||
expect(setDefaultPin).toHaveClass('disabled:pointer-events-auto')
|
||||
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
|
||||
+57
-20
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
ChipChevronDown,
|
||||
chipContentLabelClass,
|
||||
chipVariants,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
} from '@sim/emcn'
|
||||
import { Check, Pencil, Plus, Trash } from '@sim/emcn/icons'
|
||||
import { Check, Pencil, Pin, Plus, Trash } from '@sim/emcn/icons'
|
||||
import type { TableViewWire } from '@/lib/api/contracts/tables'
|
||||
import { resolveTableViewSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
|
||||
|
||||
@@ -24,7 +25,7 @@ export const ALL_ROWS_VIEW_LABEL = 'All'
|
||||
const POPOVER_CLOSE_DELAY_MS = 120
|
||||
|
||||
/** Rendered width of one action button (`p-1` + `size-3` glyph) plus its `gap-0.5`.
|
||||
* The row reserves `actions.length` of these, so keep it in step with the button
|
||||
* The row reserves `actionCount` of these, so keep it in step with the button
|
||||
* classes below — the overlay is absolutely positioned and can't size the spacer. */
|
||||
const VIEW_ACTION_SLOT_PX = 22
|
||||
|
||||
@@ -34,6 +35,7 @@ interface ViewsMenuProps {
|
||||
activeViewId: string | null
|
||||
onSelect: (viewId: string | null) => void
|
||||
onRename: (viewId: string) => void
|
||||
onSetDefault: (viewId: string) => void
|
||||
onDelete: (viewId: string) => void
|
||||
/** Starts a blank view — named first, configured after. */
|
||||
onNewView: () => void
|
||||
@@ -53,6 +55,7 @@ export const ViewsMenu = memo(function ViewsMenu({
|
||||
activeViewId,
|
||||
onSelect,
|
||||
onRename,
|
||||
onSetDefault,
|
||||
onDelete,
|
||||
onNewView,
|
||||
canEdit,
|
||||
@@ -129,6 +132,7 @@ export const ViewsMenu = memo(function ViewsMenu({
|
||||
)}
|
||||
onMouseEnter={openPopover}
|
||||
onMouseLeave={scheduleClose}
|
||||
onFocusCapture={cancelScheduledClose}
|
||||
>
|
||||
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
|
||||
Views
|
||||
@@ -146,8 +150,17 @@ export const ViewsMenu = memo(function ViewsMenu({
|
||||
key={view.id}
|
||||
label={view.name}
|
||||
isActive={view.id === activeViewId}
|
||||
isDefault={view.isDefault}
|
||||
onSelect={() => runAndClose(() => onSelect(view.id))}
|
||||
defaultState={{
|
||||
isDefault: view.isDefault,
|
||||
onSetDefault:
|
||||
canEdit && !view.isDefault
|
||||
? () => {
|
||||
cancelScheduledClose()
|
||||
onSetDefault(view.id)
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
actions={
|
||||
canEdit
|
||||
? [
|
||||
@@ -196,11 +209,16 @@ interface ViewRowAction {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
interface ViewRowDefaultState {
|
||||
isDefault: boolean
|
||||
onSetDefault?: () => void
|
||||
}
|
||||
|
||||
interface ViewRowProps {
|
||||
label: string
|
||||
isActive: boolean
|
||||
isDefault?: boolean
|
||||
onSelect: () => void
|
||||
defaultState?: ViewRowDefaultState
|
||||
actions?: ViewRowAction[]
|
||||
}
|
||||
|
||||
@@ -210,7 +228,9 @@ interface ViewRowProps {
|
||||
* on hover via opacity, not `display`) so the name never reflows or sits
|
||||
* underneath them.
|
||||
*/
|
||||
function ViewRow({ label, isActive, isDefault, onSelect, actions }: ViewRowProps) {
|
||||
function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowProps) {
|
||||
const actionCount = (actions?.length ?? 0) + (defaultState ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div className='group/view relative flex items-center'>
|
||||
<PopoverItem
|
||||
@@ -222,39 +242,56 @@ function ViewRow({ label, isActive, isDefault, onSelect, actions }: ViewRowProps
|
||||
{isActive && <Check className='size-3 text-[var(--text-icon)]' />}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
|
||||
{isDefault && (
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-micro group-hover/view:hidden'>
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{actions && (
|
||||
{actionCount > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className='shrink-0'
|
||||
style={{ width: actions.length * VIEW_ACTION_SLOT_PX }}
|
||||
style={{ width: actionCount * VIEW_ACTION_SLOT_PX }}
|
||||
/>
|
||||
)}
|
||||
</PopoverItem>
|
||||
{actions && (
|
||||
<div className='pointer-events-none absolute right-1.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/view:pointer-events-auto group-hover/view:opacity-100'>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
{actionCount > 0 && (
|
||||
<div className='pointer-events-none absolute right-1.5 flex items-center gap-0.5'>
|
||||
{actions?.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
type='button'
|
||||
variant='quiet'
|
||||
size='icon'
|
||||
aria-label={action.label}
|
||||
title={action.label}
|
||||
// The row behind is a select-the-view target; without stopping
|
||||
// propagation every action would also switch to that view.
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
className='rounded-md p-1 text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-body)]'
|
||||
className='pointer-events-none opacity-0 transition-[background-color,color,opacity] group-focus-within/view:pointer-events-auto group-focus-within/view:opacity-100 group-hover/view:pointer-events-auto group-hover/view:opacity-100'
|
||||
>
|
||||
<action.icon className='size-3' />
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
{defaultState && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='quiet'
|
||||
size='icon'
|
||||
aria-label={defaultState.isDefault ? 'Current default view' : 'Set as default'}
|
||||
title={defaultState.isDefault ? 'Current default view' : 'Set as default'}
|
||||
disabled={!defaultState.onSetDefault}
|
||||
// A disabled Button defaults to pointer-events-none, which would let
|
||||
// clicks fall through this pointer-events-none overlay to the row and
|
||||
// select the view. Keeping the pin hit-testable swallows the click —
|
||||
// disabled buttons dispatch no click events — and shows its title.
|
||||
className='pointer-events-auto disabled:pointer-events-auto'
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
defaultState.onSetDefault?.()
|
||||
}}
|
||||
>
|
||||
<Pin className={cn('size-3', defaultState.isDefault && 'fill-current')} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -121,8 +121,8 @@ interface TableProps {
|
||||
tableLocksEnabled?: boolean
|
||||
/**
|
||||
* Resolved `table-views` flag. Server-only to resolve for the same reason.
|
||||
* Defaults to `false` so the embedded mothership table — which has no server
|
||||
* context to resolve it — stays on today's Filter/Sort bar.
|
||||
* Defaults to `false` so any caller that has not resolved the flag stays on
|
||||
* today's Filter/Sort behavior.
|
||||
*/
|
||||
viewsEnabled?: boolean
|
||||
}
|
||||
@@ -735,6 +735,15 @@ export function Table({
|
||||
setViewModal({ mode: 'rename', viewId })
|
||||
}, [])
|
||||
|
||||
const handleSetDefaultView = useCallback((viewId: string) => {
|
||||
updateViewMutation.mutate(
|
||||
{ viewId, isDefault: true },
|
||||
{
|
||||
onError: (error) => toast.error(getErrorMessage(error, 'Failed to set default view')),
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleNewView = useCallback(() => {
|
||||
setViewModal({ mode: 'new' })
|
||||
}, [])
|
||||
@@ -1175,25 +1184,34 @@ export function Table({
|
||||
active: sortColumn ? { column: sortColumn, direction: sortDirection } : null,
|
||||
onSort: handleSortColumn,
|
||||
onClear: handleClearSort,
|
||||
keepOpenOnSelect: viewsEnabled,
|
||||
}),
|
||||
[columnOptions, sortColumn, sortDirection, handleSortColumn, handleClearSort]
|
||||
[columnOptions, sortColumn, sortDirection, handleSortColumn, handleClearSort, viewsEnabled]
|
||||
)
|
||||
|
||||
const handleFilterApply = (next: TablePredicate | null) => {
|
||||
setFilter(next)
|
||||
persistActiveViewConfig({ filter: next })
|
||||
}
|
||||
const handleFilterChange = useCallback(
|
||||
(next: TablePredicate | null) => {
|
||||
setFilter(next)
|
||||
persistActiveViewConfig({ filter: next })
|
||||
},
|
||||
[persistActiveViewConfig]
|
||||
)
|
||||
|
||||
const handleHiddenColumnsChange = (next: string[]) => {
|
||||
setHiddenColumns(next)
|
||||
persistActiveViewConfig({ hiddenColumns: next })
|
||||
}
|
||||
const handleHiddenColumnsChange = useCallback(
|
||||
(next: string[]) => {
|
||||
setHiddenColumns(next)
|
||||
persistActiveViewConfig({ hiddenColumns: next })
|
||||
},
|
||||
[persistActiveViewConfig]
|
||||
)
|
||||
|
||||
/**
|
||||
* "Filter by cell value" from the grid's cell context menu. Narrows the
|
||||
* PRUNED filter, so a condition the current schema already invalidated is not
|
||||
* resurrected, and opens the panel — a silently narrowed table would leave the
|
||||
* user no way to see what was applied.
|
||||
* user no way to see what was applied. Persists explicitly: the reseeded
|
||||
* panel starts signature-matched to this filter, so its gesture handlers will
|
||||
* not emit it again.
|
||||
*/
|
||||
const handleFilterByCellValue = (conditions: readonly Predicate[]) => {
|
||||
const next = withCellValueFilter(effectiveFilter, conditions)
|
||||
@@ -1496,6 +1514,7 @@ export function Table({
|
||||
activeViewId={activeView?.id ?? null}
|
||||
onSelect={handleSelectView}
|
||||
onRename={handleRenameView}
|
||||
onSetDefault={handleSetDefaultView}
|
||||
onDelete={handleDeleteView}
|
||||
onNewView={handleNewView}
|
||||
canEdit={userPermissions.canEdit}
|
||||
@@ -1519,7 +1538,8 @@ export function Table({
|
||||
key={filterSeed}
|
||||
columns={columns}
|
||||
filter={effectiveFilter}
|
||||
onApply={handleFilterApply}
|
||||
autoApply={viewsEnabled}
|
||||
onChange={handleFilterChange}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock('@sim/emcn', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
|
||||
import type { TableViewWire } from '@/lib/api/contracts/tables'
|
||||
import {
|
||||
tableRowsInfiniteOptions,
|
||||
tableRowsParamsKey,
|
||||
@@ -105,6 +106,75 @@ describe('useUpdateTableView autosave ordering', () => {
|
||||
queryKey: tableKeys.views(TABLE_ID),
|
||||
})
|
||||
})
|
||||
|
||||
it('optimistically demotes the previous default when a view is promoted', () => {
|
||||
const previousDefault: TableViewWire = {
|
||||
id: 'view-default',
|
||||
tableId: TABLE_ID,
|
||||
name: 'Default',
|
||||
config: {},
|
||||
isDefault: true,
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date('2026-08-15T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-15T01:00:00.000Z'),
|
||||
}
|
||||
const promoted: TableViewWire = {
|
||||
...previousDefault,
|
||||
id: 'view-promoted',
|
||||
name: 'My view',
|
||||
updatedAt: new Date('2026-08-15T02:00:00.000Z'),
|
||||
}
|
||||
setCache(tableKeys.views(TABLE_ID), [
|
||||
previousDefault,
|
||||
{ ...promoted, isDefault: false, updatedAt: previousDefault.updatedAt },
|
||||
])
|
||||
|
||||
const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
hook.onSuccess?.(promoted, { viewId: promoted.id, isDefault: true }, undefined, undefined)
|
||||
|
||||
expect(getCache<TableViewWire[]>(tableKeys.views(TABLE_ID))).toEqual([
|
||||
{ ...previousDefault, isDefault: false },
|
||||
promoted,
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores a stale promotion response instead of demoting the newer default', () => {
|
||||
const newerDefault: TableViewWire = {
|
||||
id: 'view-newer-default',
|
||||
tableId: TABLE_ID,
|
||||
name: 'Newer default',
|
||||
config: {},
|
||||
isDefault: true,
|
||||
createdBy: 'user-1',
|
||||
createdAt: new Date('2026-08-15T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-15T03:00:00.000Z'),
|
||||
}
|
||||
const stalePromotion: TableViewWire = {
|
||||
...newerDefault,
|
||||
id: 'view-stale',
|
||||
name: 'Stale view',
|
||||
updatedAt: new Date('2026-08-15T02:00:00.000Z'),
|
||||
}
|
||||
const cachedStaleRow: TableViewWire = {
|
||||
...stalePromotion,
|
||||
isDefault: false,
|
||||
updatedAt: new Date('2026-08-15T01:00:00.000Z'),
|
||||
}
|
||||
setCache(tableKeys.views(TABLE_ID), [newerDefault, cachedStaleRow])
|
||||
|
||||
const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
|
||||
hook.onSuccess?.(
|
||||
stalePromotion,
|
||||
{ viewId: stalePromotion.id, isDefault: true },
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(getCache<TableViewWire[]>(tableKeys.views(TABLE_ID))).toEqual([
|
||||
newerDefault,
|
||||
cachedStaleRow,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeleteColumn optimistic update', () => {
|
||||
|
||||
@@ -1579,16 +1579,33 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext)
|
||||
// Keep the active view's server baseline current immediately; the refetch
|
||||
// remains the authoritative reconciliation for concurrent collaborators.
|
||||
onSuccess: (view) => {
|
||||
queryClient.setQueryData<TableViewWire[]>(tableKeys.views(tableId), (prev) =>
|
||||
prev?.map((existing) => {
|
||||
if (existing.id !== view.id) return existing
|
||||
// Layout and view controls auto-save concurrently, and their
|
||||
// responses can arrive out of order. The DB merge is authoritative, so
|
||||
// only let a row at least as new as the cached one win — otherwise a
|
||||
// slower response rewinds the cache until the refetch lands.
|
||||
return new Date(view.updatedAt) >= new Date(existing.updatedAt) ? view : existing
|
||||
queryClient.setQueryData<TableViewWire[]>(tableKeys.views(tableId), (prev) => {
|
||||
if (!prev) return prev
|
||||
// Layout and view controls auto-save concurrently, and their
|
||||
// responses can arrive out of order. The DB merge is authoritative, so
|
||||
// only let a response at least as new as the cached row win — for
|
||||
// installing the row AND for demoting the previous default. A stale
|
||||
// response applies nothing; otherwise it would rewind the cache (or
|
||||
// strip isDefault from a newer default, leaving none) until the
|
||||
// refetch lands.
|
||||
const cached = prev.find((existing) => existing.id === view.id)
|
||||
const currentDefault = view.isDefault
|
||||
? prev.find((existing) => existing.id !== view.id && existing.isDefault)
|
||||
: undefined
|
||||
const responseTime = new Date(view.updatedAt)
|
||||
if (
|
||||
(cached && responseTime < new Date(cached.updatedAt)) ||
|
||||
(currentDefault && responseTime < new Date(currentDefault.updatedAt))
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return prev.map((existing) => {
|
||||
if (view.isDefault && existing.id !== view.id && existing.isDefault) {
|
||||
return { ...existing, isDefault: false }
|
||||
}
|
||||
return existing.id === view.id ? view : existing
|
||||
})
|
||||
)
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
// A scoped mutation only needs the database write ahead of the next
|
||||
|
||||
@@ -136,11 +136,10 @@ const FEATURE_FLAGS = {
|
||||
'table-views': {
|
||||
description:
|
||||
'Saved table views (named filter/sort/column-visibility presets) plus the column show/hide ' +
|
||||
'menu, in the table-detail options bar. UI-only gate: resolved in the table page (server) ' +
|
||||
"and passed down, so the table falls back to today's Filter/Sort bar when off. The routes " +
|
||||
'and the table_views table ship ungated — they are inert with no UI to call them, and a view ' +
|
||||
'saved during a rollout must survive the flag being toggled back off. Embedded (mothership) ' +
|
||||
'tables render without views regardless, since no server context resolves the flag there. ' +
|
||||
'menu. UI-only gate: resolved server-side for table-detail and embedded tables, then passed ' +
|
||||
"down so both surfaces fall back to today's Filter/Sort behavior when off. The routes and " +
|
||||
'the table_views table ship ungated, and new or forked tables still seed their view data, so ' +
|
||||
'a saved view survives the flag being toggled off and can be restored when it is re-enabled. ' +
|
||||
'Off-AppConfig falls back to TABLE_VIEWS.',
|
||||
fallback: 'TABLE_VIEWS',
|
||||
},
|
||||
|
||||
@@ -308,7 +308,16 @@ function formatValueForBuilder(value: JsonValue): string {
|
||||
|
||||
/* ----------------------------- v2 grammar ----------------------------- */
|
||||
|
||||
const VALUELESS_OPS = new Set<FilterOp>(['isEmpty', 'isNotEmpty', 'isNull', 'isNotNull'])
|
||||
/** Operators that carry no value — the full v2 set, a superset of the legacy
|
||||
* `VALUELESS_OPERATORS` in constants.ts (which the `$`-grammar serializer
|
||||
* still reads and must not grow). Widened to `ReadonlySet<string>` so UI rule
|
||||
* operators can be tested without a cast. */
|
||||
export const VALUELESS_OPS: ReadonlySet<string> = new Set<FilterOp>([
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'isNull',
|
||||
'isNotNull',
|
||||
])
|
||||
|
||||
function ruleToPredicate(rule: FilterRule, keepAsText = false): Predicate {
|
||||
const op = rule.operator as FilterOp
|
||||
|
||||
Reference in New Issue
Block a user