}
{label}
- {isDefault && (
-
- Default
-
- )}
- {actions && (
+ {actionCount > 0 && (
)}
- {actions && (
-
- {actions.map((action) => (
+ {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 812b5eb3209..3adec02dde8 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -80,6 +80,7 @@ import {
type SelectionSnapshot,
TableActionBar,
TableFilter,
+ type TableFilterHandle,
TableGrid,
ViewsMenu,
type WorkflowConfig,
@@ -240,6 +241,7 @@ export function Table({
})
const [filter, setFilter] = useState
(null)
const [filterOpen, setFilterOpen] = useState(false)
+ const tableFilterRef = useRef(null)
/** Bumped whenever the filter is replaced from outside the panel, to re-seed
* its rule rows. See {@link replaceFilter}. */
const [filterSeed, setFilterSeed] = useState(0)
@@ -250,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)
@@ -402,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)
}, [])
@@ -669,6 +675,7 @@ export function Table({
const handleSelectView = useCallback(
(viewId: string | null) => {
+ tableFilterRef.current?.flush()
setTableParams({ view: viewId ?? ALL_VIEW_PARAM })
},
[setTableParams]
@@ -678,6 +685,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' })
}, [])
@@ -1089,10 +1105,13 @@ export function Table({
[columnOptions, sortColumn, sortDirection, handleSortColumn, handleClearSort]
)
- 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)
@@ -1103,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)
}
@@ -1327,9 +1350,13 @@ 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.
- const handleToggleFilter = useCallback(() => setFilterOpen((prev) => !prev), [])
+ // 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)
+ }, [filterOpen])
const filterConfig = useMemo(
() => ({
mode: 'toggle' as const,
@@ -1404,6 +1431,7 @@ export function Table({
activeViewId={activeView?.id ?? null}
onSelect={handleSelectView}
onRename={handleRenameView}
+ onSetDefault={handleSetDefaultView}
onDelete={handleDeleteView}
onNewView={handleNewView}
canEdit={userPermissions.canEdit}
@@ -1424,11 +1452,11 @@ export function Table({
/>
{filterOpen && (
setFilterOpen(false)}
+ onChange={handleFilterChange}
/>
)}
({
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(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-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 c2c9c4651d8..bb8eeead625 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -1563,16 +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) => {
- 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(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 }
+ }
+ return existing.id === view.id ? view : existing
})
- )
+ })
},
onSettled: () => {
// A scoped mutation only needs the database write ahead of the next
diff --git a/apps/sim/lib/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts
index 981d62294c8..4063bf53b19 100644
--- a/apps/sim/lib/table/query-builder/converters.ts
+++ b/apps/sim/lib/table/query-builder/converters.ts
@@ -308,7 +308,16 @@ function formatValueForBuilder(value: JsonValue): string {
/* ----------------------------- v2 grammar ----------------------------- */
-const VALUELESS_OPS = new Set(['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