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:
Justin Blumencranz
2026-08-20 10:47:28 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 38630ff46d
commit 85902eb7ff
13 changed files with 1032 additions and 160 deletions
@@ -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)
})
})
@@ -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 {
@@ -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'])
})
})
@@ -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>
)
}
@@ -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' }] },
],
})
})
})
@@ -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 {
@@ -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()
@@ -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)}
/>
)}
+70
View File
@@ -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', () => {
+26 -9
View File
@@ -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
+4 -5
View File
@@ -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',
},
+10 -1
View File
@@ -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