}
{label}
- {isDefault && (
-
- Default
-
- )}
- {actions && (
+ {actionCount > 0 && (
)}
- {actions && (
-
- {actions.map((action) => (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index f368f06908d..b94138e4385 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -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)}
/>
)}
diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts
index 2d4f6eb0b0d..0f81a28108f 100644
--- a/apps/sim/hooks/queries/tables.test.ts
+++ b/apps/sim/hooks/queries/tables.test.ts
@@ -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
(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(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 fbe80d0abda..4d54cfb8bb8 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -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(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)
+ 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
diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts
index c068326a648..3a19f2ee368 100644
--- a/apps/sim/lib/core/config/feature-flags.ts
+++ b/apps/sim/lib/core/config/feature-flags.ts
@@ -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',
},
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