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 8dd2a268462..c8ef8d6a9eb 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
@@ -2,6 +2,7 @@
import { memo, useEffect, useRef, useState } from 'react'
import {
+ Button,
ChipChevronDown,
chipContentLabelClass,
chipVariants,
@@ -252,9 +253,11 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr
{actionCount > 0 && (
{actions?.map((action) => (
-
+
))}
{defaultState && (
-
+
)}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index d5d49b2268a..7e8a7d5a918 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -1196,18 +1196,21 @@ export function Table({
[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. Persists explicitly: the reseeded
- * panel starts signature-matched to this filter, so its debounce alone would
- * never save it.
+ * 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)
From ea127a8594863acffaf611de2beb577807177483 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:10:32 -0700
Subject: [PATCH 08/13] fix(tables): reject stale default promotions
---
apps/sim/hooks/queries/tables.test.ts | 4 ++--
apps/sim/hooks/queries/tables.ts | 11 ++++++++++-
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts
index 0cdf1ea939d..0f81a28108f 100644
--- a/apps/sim/hooks/queries/tables.test.ts
+++ b/apps/sim/hooks/queries/tables.test.ts
@@ -153,12 +153,12 @@ describe('useUpdateTableView autosave ordering', () => {
...newerDefault,
id: 'view-stale',
name: 'Stale view',
- updatedAt: new Date('2026-08-15T01:00:00.000Z'),
+ updatedAt: new Date('2026-08-15T02:00:00.000Z'),
}
const cachedStaleRow: TableViewWire = {
...stalePromotion,
isDefault: false,
- updatedAt: new Date('2026-08-15T02:00:00.000Z'),
+ updatedAt: new Date('2026-08-15T01:00:00.000Z'),
}
setCache(tableKeys.views(TABLE_ID), [newerDefault, cachedStaleRow])
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index eff2564ae64..4d54cfb8bb8 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -1589,7 +1589,16 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext)
// 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
+ 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 }
From 87a67e1df2b99860d7d51b1acc936b4b07b94eec Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:35:15 -0700
Subject: [PATCH 09/13] fix(tables): preserve filters during rule transitions
---
.../table-filter/table-filter.test.tsx | 27 ++++++++++
.../components/table-filter/table-filter.tsx | 50 +++++++++++--------
2 files changed, 56 insertions(+), 21 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 8662ea28d01..edb0629cab6 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
@@ -128,6 +128,33 @@ describe('TableFilter', () => {
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
('[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('loads a saved OR filter verbatim without an unsolicited autosave', () => {
const onChange = vi.fn()
renderFilter(onChange, {
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 97028bafab3..bf4f1dce080 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
@@ -32,12 +32,14 @@ function toAppliedPredicate(
rules: FilterRule[],
columns: ColumnDefinition[]
): TablePredicate | null {
- const validRules = rules.filter(
- (rule) => rule.column && (rule.value || VALUELESS_OPS.has(rule.operator))
- )
+ const validRules = 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
@@ -61,11 +63,14 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
lastAppliedFilterRef.current ??= JSON.stringify(toAppliedPredicate(rules, columns))
const applyRules = useCallback(
- (update: (current: FilterRule[]) => FilterRule[]) => {
+ (update: (current: FilterRule[]) => FilterRule[], deferIncompleteRuleId?: string) => {
const nextRules = update(rulesRef.current)
rulesRef.current = nextRules
setRules(nextRules)
+ const deferredRule = nextRules.find((rule) => rule.id === deferIncompleteRuleId)
+ if (deferredRule && !isCompleteRule(deferredRule)) return
+
const nextFilter = toAppliedPredicate(nextRules, columns)
const signature = JSON.stringify(nextFilter)
if (signature === lastAppliedFilterRef.current) return
@@ -102,8 +107,9 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
const handleUpdate = useCallback(
(id: string, field: keyof FilterRule, value: string) => {
- applyRules((current) =>
- current.map((rule) => (rule.id === id ? { ...rule, [field]: value } : rule))
+ applyRules(
+ (current) => current.map((rule) => (rule.id === id ? { ...rule, [field]: value } : rule)),
+ field === 'operator' ? id : undefined
)
},
[applyRules]
@@ -128,21 +134,23 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
// apply against a select column and be rejected server-side.
const handleColumnChange = useCallback(
(id: string, columnId: string) => {
- 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: '' }
- })
+ 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
)
},
[applyRules, columnById]
From 299f8ea1abea38fe684e88af574e691544ab554a Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 19 Aug 2026 16:59:41 -0700
Subject: [PATCH 10/13] fix(tables): isolate flagged view interactions
---
.../resource-options.test.tsx | 25 ++++++
.../resource-options/resource-options.tsx | 7 +-
.../table-filter/table-filter.test.tsx | 39 ++++++++-
.../components/table-filter/table-filter.tsx | 79 +++++++++++++++++--
.../[workspaceId]/tables/[tableId]/table.tsx | 9 ++-
apps/sim/lib/core/config/feature-flags.ts | 9 +--
6 files changed, 147 insertions(+), 21 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx
index bb825f886c5..db10cad80a4 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx
@@ -82,6 +82,7 @@ describe('SortDropdown', () => {
active: { column: 'name', direction: 'asc' },
onSort,
onClear,
+ keepOpenOnSelect: true,
}}
/>
)
@@ -98,4 +99,28 @@ describe('SortDropdown', () => {
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(
+
+ )
+ })
+
+ const item = document.body.querySelector('[role="menuitem"]')
+ act(() => item?.click())
+
+ expect(onSort).toHaveBeenCalledWith('name', 'desc')
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
index 4caae455b2b..6f30372b129 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
@@ -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 (
@@ -301,7 +302,7 @@ export const SortDropdown = memo(function SortDropdown({
<>
{
- event.preventDefault()
+ if (keepOpenOnSelect) event.preventDefault()
onClear()
}}
>
@@ -320,7 +321,7 @@ export const SortDropdown = memo(function SortDropdown({
{
- event.preventDefault()
+ if (keepOpenOnSelect) event.preventDefault()
if (isActive) {
onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc')
} else {
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 edb0629cab6..896e6fc6c45 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
@@ -26,10 +26,20 @@ afterEach(() => {
function renderFilter(
onChange: (filter: TablePredicate | null) => void,
- filter: TablePredicate | null = null
+ filter: TablePredicate | null = null,
+ autoApply = true,
+ onClose: () => void = vi.fn()
) {
act(() => {
- root.render()
+ root.render(
+
+ )
})
}
@@ -106,6 +116,25 @@ describe('TableFilter', () => {
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, {
@@ -174,10 +203,12 @@ describe('TableFilter', () => {
it('does not autosave when columns refresh without a user edit', () => {
const onChange = vi.fn()
act(() => {
- root.render()
+ root.render()
})
act(() => {
- root.render()
+ root.render(
+
+ )
})
expect(onChange).not.toHaveBeenCalled()
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 bf4f1dce080..5201bdbdd17 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,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'
@@ -43,10 +43,18 @@ function isCompleteRule(rule: FilterRule): boolean {
interface TableFilterProps {
columns: ColumnDefinition[]
filter: TablePredicate | null
+ autoApply?: boolean
onChange: (filter: TablePredicate | null) => void
+ onClose?: () => void
}
-export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
+export function TableFilter({
+ columns,
+ filter,
+ autoApply = false,
+ onChange,
+ onClose,
+}: TableFilterProps) {
const lastAppliedFilterRef = useRef(undefined)
const [rules, setRules] = useState(() => {
const fromFilter = predicateToFilterRules(filter)
@@ -67,6 +75,7 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
const nextRules = update(rulesRef.current)
rulesRef.current = nextRules
setRules(nextRules)
+ if (!autoApply) return
const deferredRule = nextRules.find((rule) => rule.id === deferIncompleteRuleId)
if (deferredRule && !isCompleteRule(deferredRule)) return
@@ -77,7 +86,7 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
lastAppliedFilterRef.current = signature
onChange(nextFilter)
},
- [columns, onChange]
+ [autoApply, columns, onChange]
)
// `value` is the filter field key (column id); `label` is what the user sees.
@@ -97,12 +106,26 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
const handleRemove = useCallback(
(id: string) => {
+ 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 next = current.filter((rule) => rule.id !== id)
return next.length > 0 ? next : [createRule(columns)]
})
},
- [applyRules, columns]
+ [applyRules, autoApply, columns, onChange, onClose]
)
const handleUpdate = useCallback(
@@ -156,6 +179,17 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
[applyRules, columnById]
)
+ const handleApply = useCallback(() => {
+ onChange(toAppliedPredicate(rulesRef.current, columns))
+ }, [columns, onChange])
+
+ const handleClear = () => {
+ const resetRules = [createRule(columns)]
+ rulesRef.current = resetRules
+ setRules(resetRules)
+ onChange(null)
+ }
+
return (
@@ -169,11 +203,13 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) {
onUpdate={handleUpdate}
onColumnChange={handleColumnChange}
onRemove={handleRemove}
+ autoApply={autoApply}
+ onApply={handleApply}
onToggleLogical={handleToggleLogical}
/>
))}
-
+
Add filter
+ {!autoApply && (
+
+ {filter !== null && (
+
+ )}
+
+
+ )}
@@ -197,6 +250,8 @@ 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
}
@@ -208,6 +263,8 @@ const FilterRuleRow = memo(function FilterRuleRow({
onUpdate,
onColumnChange,
onRemove,
+ autoApply,
+ onApply,
onToggleLogical,
}: FilterRuleRowProps) {
// Keep a stale column id selectable/visible (e.g. after the column was
@@ -281,11 +338,21 @@ const FilterRuleRow = memo(function FilterRuleRow({
matchTriggerWidth={false}
className='min-w-[100px] flex-1'
/>
- ) : (
+ ) : autoApply ? (
onUpdate(rule.id, 'value', value)}
/>
+ ) : (
+ onUpdate(rule.id, 'value', event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') onApply()
+ }}
+ placeholder='Enter a value'
+ className='flex-1'
+ />
)}