}
{label}
- {isDefault && (
-
- Default
-
- )}
- {actions && (
+ {actionCount > 0 && (
)}
- {actions && (
-
- {actions.map((action) => (
+ {actionCount > 0 && (
+
+ {actions?.map((action) => (
))}
+ {defaultState && (
+
+ )}
)}
From e080527384a539860b020b51616d07ad401bf816 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sat, 15 Aug 2026 19:47:47 -0700
Subject: [PATCH 3/6] fix(tables): guard view autosave against echo remounts
and stale responses
Co-Authored-By: Claude Fable 5
---
.../[workspaceId]/tables/[tableId]/table.tsx | 23 +++++++----
apps/sim/hooks/queries/tables.test.ts | 38 +++++++++++++++++++
apps/sim/hooks/queries/tables.ts | 23 ++++++-----
3 files changed, 68 insertions(+), 16 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index 968f2f5955d..3adec02dde8 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -252,8 +252,8 @@ export function Table({
const [{ sort: sortColumn, dir: sortDirection, view: activeViewId }, setTableParams] =
useQueryStates(tableDetailParsers, tableDetailUrlKeys)
- // Read-only mirrors for the resolve effect: it must know whether the user has
- // already applied a filter / hidden columns without re-running when they change.
+ // Read-only mirrors for the resolve effect and replaceFilter's echo check:
+ // both must read the current values without re-running when they change.
const filterRef = useRef(filter)
filterRef.current = filter
const hiddenColumnsRef = useRef(hiddenColumns)
@@ -404,9 +404,13 @@ export function Table({
* this an open panel keeps showing the rules of the filter it replaced.
*
* The remount discards an unapplied draft, which is the point — the rules on
- * screen must be the rules in effect.
+ * screen must be the rules in effect. An incoming filter identical to the
+ * current one is skipped entirely: the resolve effect re-applies the config
+ * after this client's own autosave settles, and letting that echo remount an
+ * open panel would wipe keystrokes typed since the flush and steal focus.
*/
const replaceFilter = useCallback((next: TablePredicate | null) => {
+ if (JSON.stringify(next) === JSON.stringify(filterRef.current)) return
setFilter(next)
setFilterSeed((seed) => seed + 1)
}, [])
@@ -1118,10 +1122,14 @@ export function Table({
* "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 debounce alone would
+ * never save it.
*/
const handleFilterByCellValue = (conditions: readonly Predicate[]) => {
- replaceFilter(withCellValueFilter(effectiveFilter, conditions))
+ const next = withCellValueFilter(effectiveFilter, conditions)
+ replaceFilter(next)
+ persistActiveViewConfig({ filter: next })
setFilterOpen(true)
}
@@ -1342,8 +1350,9 @@ export function Table({
// a one-line query forward.
const { data: executionLog } = useLogByExecutionId(workspaceId, executionId)
- // Stable identity so the memoized Resource.Options can bail — an inline
- // object literal (with an inline arrow) would defeat its memo every render.
+ // Identity only changes with filterOpen (the flush targets the open panel),
+ // so unrelated parent re-renders still let the memoized Resource.Options
+ // bail; filterConfig below re-memoizes on filterOpen anyway.
const handleToggleFilter = useCallback(() => {
if (filterOpen) tableFilterRef.current?.flush()
setFilterOpen(!filterOpen)
diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts
index d0e63a31d86..0cdf1ea939d 100644
--- a/apps/sim/hooks/queries/tables.test.ts
+++ b/apps/sim/hooks/queries/tables.test.ts
@@ -137,6 +137,44 @@ describe('useUpdateTableView autosave ordering', () => {
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-15T01:00:00.000Z'),
+ }
+ const cachedStaleRow: TableViewWire = {
+ ...stalePromotion,
+ isDefault: false,
+ updatedAt: new Date('2026-08-15T02: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(tableKeys.views(TABLE_ID))).toEqual([
+ newerDefault,
+ cachedStaleRow,
+ ])
+ })
})
describe('useDeleteColumn optimistic update', () => {
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 8198e79829f..bb8eeead625 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -1563,19 +1563,24 @@ 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(tableKeys.views(tableId), (prev) =>
- prev?.map((existing) => {
+ queryClient.setQueryData(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)
+ if (cached && new Date(view.updatedAt) < new Date(cached.updatedAt)) return prev
+ return prev.map((existing) => {
if (view.isDefault && existing.id !== view.id && existing.isDefault) {
return { ...existing, isDefault: false }
}
- 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
+ return existing.id === view.id ? view : existing
})
- )
+ })
},
onSettled: () => {
// A scoped mutation only needs the database write ahead of the next
From de5766ca205c1360b9178e40ea0d5fe53fa4205b Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sat, 15 Aug 2026 19:47:48 -0700
Subject: [PATCH 4/6] fix(tables): keep and/or filter toggles, autosave only
real edits
Co-Authored-By: Claude Fable 5
---
.../table-filter/table-filter.test.tsx | 44 +++++++++++++--
.../components/table-filter/table-filter.tsx | 54 ++++++++++++++-----
.../sim/lib/table/query-builder/converters.ts | 11 +++-
3 files changed, 89 insertions(+), 20 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx
index 1e9baccc51d..d304202a49c 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx
@@ -62,7 +62,7 @@ describe('TableFilter', () => {
})
})
- it('uses fixed AND conjunctions without apply or clear actions', () => {
+ 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')
@@ -70,11 +70,14 @@ describe('TableFilter', () => {
act(() => addFilter?.click())
- const conjunction = Array.from(container.querySelectorAll('*')).find(
- (element) => element.textContent?.trim() === 'and'
+ const conjunction = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === 'and'
)
expect(conjunction).toBeDefined()
- expect(conjunction?.closest('button')).toBeNull()
+
+ act(() => conjunction?.click())
+ expect(conjunction?.textContent?.trim()).toBe('or')
+
expect(container.textContent).not.toContain('Apply filter')
expect(container.textContent).not.toContain('Clear filters')
})
@@ -141,7 +144,34 @@ describe('TableFilter', () => {
).toBe('')
})
- it('normalizes a previously saved OR filter to AND', () => {
+ it('preserves saved isNull conditions instead of dropping them', () => {
+ const onChange = vi.fn()
+ renderFilter(onChange, { all: [{ field: 'col-name', op: 'isNull' }] })
+
+ act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
+
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('loads a saved OR filter verbatim without an unsolicited autosave', () => {
+ const onChange = vi.fn()
+ renderFilter(onChange, {
+ any: [
+ { all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] },
+ { all: [{ field: 'col-name', op: 'eq', value: 'Grace' }] },
+ ],
+ })
+
+ const orToggle = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === 'or'
+ )
+ expect(orToggle).toBeDefined()
+
+ act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('merges the OR groups when the conjunction is toggled back to and', () => {
const onChange = vi.fn()
renderFilter(onChange, {
any: [
@@ -150,6 +180,10 @@ describe('TableFilter', () => {
],
})
+ const orToggle = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === 'or'
+ )
+ act(() => orToggle?.click())
act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
expect(onChange).toHaveBeenCalledWith({
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
index b3bc0887e3d..1333725d391 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
@@ -19,11 +19,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) =>
@@ -39,6 +39,17 @@ function selectFilterOperators(column: ColumnDefinition | undefined): Set rule.column && (rule.value || VALUELESS_OPS.has(rule.operator))
+ )
+ return filterRulesToPredicate(validRules, columns)
+}
+
interface TableFilterProps {
columns: ColumnDefinition[]
filter: TablePredicate | null
@@ -58,17 +69,21 @@ export const TableFilter = forwardRef(funct
{ columns, filter, onChange },
ref
) {
- const lastAppliedFilterRef = useRef(JSON.stringify(filter))
+ const lastAppliedFilterRef = useRef(undefined)
const onChangeRef = useRef(onChange)
const pendingFilterRef = useRef(null)
const timeoutRef = useRef | null>(null)
const [rules, setRules] = useState(() => {
- const fromFilter = predicateToFilterRules(filter).map((rule) => ({
- ...rule,
- logicalOperator: 'and' as const,
- }))
+ const fromFilter = predicateToFilterRules(filter)
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
})
+ // 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 schedule 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))
onChangeRef.current = onChange
// `value` is the filter field key (column id); `label` is what the user sees.
@@ -100,6 +115,14 @@ export const TableFilter = forwardRef(funct
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
}, [])
+ const handleToggleLogical = useCallback((id: string) => {
+ setRules((prev) =>
+ prev.map((r) =>
+ r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r
+ )
+ )
+ }, [])
+
// 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
// back to `eq` — otherwise a stale free-text value or a range operator would
@@ -140,10 +163,7 @@ export const TableFilter = forwardRef(funct
useImperativeHandle(ref, () => ({ flush }), [flush])
useEffect(() => {
- const validRules = rules.filter(
- (rule) => rule.column && (rule.value || VALUELESS_OPERATORS.has(rule.operator))
- )
- const nextFilter = filterRulesToPredicate(validRules, columns)
+ const nextFilter = toAppliedPredicate(rules, columns)
const signature = JSON.stringify(nextFilter)
if (signature === lastAppliedFilterRef.current) {
pendingFilterRef.current = null
@@ -178,6 +198,7 @@ export const TableFilter = forwardRef(funct
onUpdate={handleUpdate}
onColumnChange={handleColumnChange}
onRemove={handleRemove}
+ onToggleLogical={handleToggleLogical}
/>
))}
@@ -205,6 +226,7 @@ interface FilterRuleRowProps {
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
onColumnChange: (id: string, columnId: string) => void
onRemove: (id: string) => void
+ onToggleLogical: (id: string) => void
}
const FilterRuleRow = memo(function FilterRuleRow({
@@ -215,6 +237,7 @@ const FilterRuleRow = memo(function FilterRuleRow({
onUpdate,
onColumnChange,
onRemove,
+ onToggleLogical,
}: FilterRuleRowProps) {
// Keep a stale column id selectable/visible (e.g. after the column was
// removed) instead of falling back to the placeholder while the rule still
@@ -247,9 +270,12 @@ const FilterRuleRow = memo(function FilterRuleRow({
{isFirst ? (
Where
) : (
-
- and
-
+
)}
- {VALUELESS_OPERATORS.has(rule.operator) ? (
+ {VALUELESS_OPS.has(rule.operator) ? (
) : isSelect ? (
(['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` so UI rule
+ * operators can be tested without a cast. */
+export const VALUELESS_OPS: ReadonlySet = new Set([
+ 'isEmpty',
+ 'isNotEmpty',
+ 'isNull',
+ 'isNotNull',
+])
function ruleToPredicate(rule: FilterRule, keepAsText = false): Predicate {
const op = rule.operator as FilterOp
From 0d6558099794e19650798b71952704ec42d3c678 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sat, 15 Aug 2026 19:47:49 -0700
Subject: [PATCH 5/6] fix(tables): compute view-row action spacer and cover the
default pin
Co-Authored-By: Claude Fable 5
---
.../[tableId]/components/views-menu/views-menu.test.tsx | 4 ++++
.../tables/[tableId]/components/views-menu/views-menu.tsx | 8 +++++++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx
index 614ad42650e..9a98d10bc96 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx
@@ -102,6 +102,10 @@ describe('ViewsMenu', () => {
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()
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx
index b49f3d03bfe..1d572f88d31 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx
@@ -23,6 +23,11 @@ export const ALL_ROWS_VIEW_LABEL = 'All'
/** Matches the breadcrumb location popover's hover-intent grace period. */
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 `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
+
interface ViewsMenuProps {
views: TableViewWire[]
/** `null` selects the legacy "All" state while a table awaits backfill. */
@@ -235,7 +240,8 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr
{actionCount > 0 && (
)}
From e4f04037eab74acd48eb03044b69d9ec2d3c0afb Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Mon, 17 Aug 2026 12:14:32 -0700
Subject: [PATCH 6/6] feat(tables): apply filter text on enter or blur instead
of a debounce
Co-Authored-By: Claude Fable 5
---
.../components/table-filter/index.ts | 2 +-
.../table-filter/table-filter.test.tsx | 132 +++++++-----------
.../components/table-filter/table-filter.tsx | 118 +++++++---------
.../[workspaceId]/tables/[tableId]/table.tsx | 14 +-
4 files changed, 109 insertions(+), 157 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts
index 50e871c271a..8cd08769fea 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts
@@ -1 +1 @@
-export { TableFilter, type TableFilterHandle } from './table-filter'
+export { TableFilter } from './table-filter'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx
index d304202a49c..d619a6151a8 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx
@@ -1,15 +1,11 @@
/**
* @vitest-environment jsdom
*/
-import { act, createRef, type Ref } from 'react'
+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 {
- FILTER_DEBOUNCE_MS,
- TableFilter,
- type TableFilterHandle,
-} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter'
+import { TableFilter } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter'
const COLUMNS: ColumnDefinition[] = [{ id: 'col-name', name: 'Name', type: 'string' }]
@@ -18,7 +14,6 @@ let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
- vi.useFakeTimers()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -27,41 +22,70 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount())
container.remove()
- vi.useRealTimers()
})
function renderFilter(
onChange: (filter: TablePredicate | null) => void,
- filter: TablePredicate | null = null,
- ref?: Ref
+ filter: TablePredicate | null = null
) {
act(() => {
- root.render()
+ root.render()
})
}
+function valueInput(): HTMLInputElement | null {
+ return container.querySelector('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('applies text filters after a short typing delay', () => {
- const onApply = vi.fn()
- renderFilter(onApply)
- const input = container.querySelector('input[placeholder="Enter a value"]')
+ it('commits a typed value on blur, not per keystroke', () => {
+ const onChange = vi.fn()
+ renderFilter(onChange)
+ const input = valueInput()
expect(input).not.toBeNull()
- act(() => {
- if (!input) return
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada')
- input.dispatchEvent(new Event('input', { bubbles: true }))
- })
+ act(() => typeInto(input, 'Ada'))
+ expect(onChange).not.toHaveBeenCalled()
- expect(onApply).not.toHaveBeenCalled()
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1))
- expect(onApply).not.toHaveBeenCalled()
- act(() => vi.advanceTimersByTime(1))
- expect(onApply).toHaveBeenCalledWith({
+ 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) =>
@@ -82,51 +106,7 @@ describe('TableFilter', () => {
expect(container.textContent).not.toContain('Clear filters')
})
- it('flushes the pending filter when the panel closes before the delay', () => {
- const onChange = vi.fn()
- const filterRef = createRef()
- renderFilter(onChange, null, filterRef)
- const input = container.querySelector('input[placeholder="Enter a value"]')
-
- act(() => {
- if (!input) return
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada')
- input.dispatchEvent(new Event('input', { bubbles: true }))
- })
- act(() => {
- filterRef.current?.flush()
- })
-
- expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith({
- all: [{ field: 'col-name', op: 'eq', value: 'Ada' }],
- })
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
- expect(onChange).toHaveBeenCalledTimes(1)
- })
-
- it('cancels the previous debounce when typing continues', () => {
- const onChange = vi.fn()
- renderFilter(onChange)
- const input = container.querySelector('input[placeholder="Enter a value"]')
- const setInput = (value: string) => {
- if (!input) return
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
- input.dispatchEvent(new Event('input', { bubbles: true }))
- }
-
- act(() => setInput('Ada'))
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1))
- act(() => setInput('Grace'))
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
-
- expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith({
- all: [{ field: 'col-name', op: 'eq', value: 'Grace' }],
- })
- })
-
- it('clears the active filter when its last rule is removed', () => {
+ 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' }],
@@ -136,20 +116,15 @@ describe('TableFilter', () => {
'button[aria-label="Remove filter"]'
)
act(() => removeButton?.click())
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
expect(onChange).toHaveBeenCalledWith(null)
- expect(
- container.querySelector('input[placeholder="Enter a value"]')?.value
- ).toBe('')
+ 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' }] })
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
-
expect(onChange).not.toHaveBeenCalled()
})
@@ -166,12 +141,10 @@ describe('TableFilter', () => {
(button) => button.textContent?.trim() === 'or'
)
expect(orToggle).toBeDefined()
-
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
expect(onChange).not.toHaveBeenCalled()
})
- it('merges the OR groups when the conjunction is toggled back to and', () => {
+ it('merges the OR groups as soon as the conjunction is toggled back to and', () => {
const onChange = vi.fn()
renderFilter(onChange, {
any: [
@@ -184,7 +157,6 @@ describe('TableFilter', () => {
(button) => button.textContent?.trim() === 'or'
)
act(() => orToggle?.click())
- act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS))
expect(onChange).toHaveBeenCalledWith({
all: [
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
index 1333725d391..7e0e4512d92 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
@@ -1,15 +1,6 @@
'use client'
-import {
- forwardRef,
- memo,
- useCallback,
- useEffect,
- useImperativeHandle,
- useMemo,
- useRef,
- useState,
-} from 'react'
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, ChipDropdown, ChipInput } from '@sim/emcn'
import { Plus, X } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
@@ -33,8 +24,6 @@ const MULTI_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) =>
MULTI_SELECT_FILTER_OPERATORS.has(o.value)
)
-export const FILTER_DEBOUNCE_MS = 250
-
function selectFilterOperators(column: ColumnDefinition | undefined): Set {
return column?.multiple ? MULTI_SELECT_FILTER_OPERATORS : SINGLE_SELECT_FILTER_OPERATORS
}
@@ -56,23 +45,9 @@ interface TableFilterProps {
onChange: (filter: TablePredicate | null) => void
}
-export interface TableFilterHandle {
- flush: () => void
-}
-
-interface PendingFilter {
- filter: TablePredicate | null
- signature: string
-}
-
-export const TableFilter = forwardRef(function TableFilter(
- { columns, filter, onChange },
- ref
-) {
+export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
const lastAppliedFilterRef = useRef(undefined)
const onChangeRef = useRef(onChange)
- const pendingFilterRef = useRef(null)
- const timeoutRef = useRef | null>(null)
const [rules, setRules] = useState(() => {
const fromFilter = predicateToFilterRules(filter)
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
@@ -80,7 +55,7 @@ export const TableFilter = forwardRef(funct
// 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 schedule an unedited autosave of that lossy form the
+ // 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))
@@ -149,41 +124,19 @@ export const TableFilter = forwardRef(funct
[columnById]
)
- const flush = useCallback(() => {
- const pending = pendingFilterRef.current
- if (!pending) return
-
- if (timeoutRef.current) clearTimeout(timeoutRef.current)
- timeoutRef.current = null
- pendingFilterRef.current = null
- lastAppliedFilterRef.current = pending.signature
- onChangeRef.current(pending.filter)
- }, [])
-
- useImperativeHandle(ref, () => ({ flush }), [flush])
-
+ // Applies on every rules change. Rules only change on completed gestures —
+ // dropdown picks, row add/remove, conjunction toggles, and the value field's
+ // Enter/blur commit ({@link FilterValueInput} buffers keystrokes locally) —
+ // so nothing is ever pending and there is nothing to lose on unmount. The
+ // signature guard keeps no-op changes (a blank row added, an untouched
+ // reseed) from writing.
useEffect(() => {
const nextFilter = toAppliedPredicate(rules, columns)
const signature = JSON.stringify(nextFilter)
- if (signature === lastAppliedFilterRef.current) {
- pendingFilterRef.current = null
- return
- }
-
- const pending = { filter: nextFilter, signature }
- pendingFilterRef.current = pending
- const timeout = setTimeout(() => {
- if (pendingFilterRef.current !== pending) return
- timeoutRef.current = null
- flush()
- }, FILTER_DEBOUNCE_MS)
- timeoutRef.current = timeout
-
- return () => {
- clearTimeout(timeout)
- if (timeoutRef.current === timeout) timeoutRef.current = null
- }
- }, [rules, columns, flush])
+ if (signature === lastAppliedFilterRef.current) return
+ lastAppliedFilterRef.current = signature
+ onChangeRef.current(nextFilter)
+ }, [rules, columns])
return (
@@ -216,7 +169,7 @@ export const TableFilter = forwardRef
(funct
)
-})
+}
interface FilterRuleRowProps {
rule: FilterRule
@@ -311,11 +264,9 @@ const FilterRuleRow = memo(function FilterRuleRow({
className='min-w-[100px] flex-1'
/>
) : (
-