Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/sim/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ bun-debug.log*

# testing
/coverage
/e2e/.cache/
/e2e/.runs/
/playwright-report/
/test-results/

# next.js
/.next/
Expand Down Expand Up @@ -45,4 +49,4 @@ next-env.d.ts
# Uploads
/uploads

.trigger
.trigger
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { TableViewWire } from '@/lib/api/contracts/tables'
import { resolveTableViewSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'

/** Legacy label for tables that do not yet have a persisted default view. */
export const ALL_ROWS_VIEW_LABEL = 'All'
export const ALL_ROWS_VIEW_LABEL = 'Default view'

/** Matches the breadcrumb location popover's hover-intent grace period. */
const POPOVER_CLOSE_DELAY_MS = 120
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type {
WorkflowGroup,
} from '@/lib/table'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { prunePredicateForColumns } from '@/lib/table/query-builder/converters'
import {
prunePredicateForColumns,
pruneViewPredicateForColumns,
} from '@/lib/table/query-builder/converters'
import { resolveWorkflowGroupDeploymentMode } from '@/lib/table/workflow-groups/deployment-mode'
import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs'
import { getBlock } from '@/blocks'
Expand Down Expand Up @@ -98,10 +101,11 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
// server rejects outright, which would fail every subsequent rows query. Prune
// here, above every consumer of the rows query key, so the paged helpers below
// can't rebuild the key from the unpruned filter and drift.
const filter = useMemo(
() => prunePredicateForColumns(queryOptions.filter ?? null, tableData?.schema?.columns ?? []),
[queryOptions.filter, tableData?.schema?.columns]
)
const filter = useMemo(() => {
const columns = tableData?.schema?.columns
const compatible = prunePredicateForColumns(queryOptions.filter ?? null, columns ?? [])
return columns ? pruneViewPredicateForColumns(compatible, columns) : compatible
}, [queryOptions.filter, tableData?.schema?.columns])

const {
data: rowsData,
Expand Down
27 changes: 25 additions & 2 deletions apps/sim/lib/table/query-builder/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@

import { generateShortId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { columnMatchesRef } from '@/lib/table/column-keys'
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
import { TableQueryValidationError } from '@/lib/table/errors'
import {
MULTI_SELECT_FILTER_OPERATORS,
SINGLE_SELECT_FILTER_OPERATORS,
} from '@/lib/table/query-builder/constants'
import { validatePredicateShape } from '@/lib/table/query-builder/validate'
import { SYSTEM_COLUMN_FIELDS, validatePredicateShape } from '@/lib/table/query-builder/validate'
import type {
ColumnDefinition,
Filter,
FilterOp,
FilterRule,
JsonValue,
Predicate,
PredicateNode,
Sort,
SortRule,
SortSpec,
Expand Down Expand Up @@ -143,6 +144,28 @@ export function prunePredicateForColumns(
return filterRulesToPredicate(kept, columns)
}

/** Removes deleted stable column ids and empty groups from a saved view predicate. */
export function pruneViewPredicateForColumns(
predicate: TablePredicate | null,
columns: ColumnDefinition[]
): TablePredicate | null {
if (!predicate) return null
const live = new Set(columns.map(getColumnId))

const prune = (node: PredicateNode): PredicateNode | null => {
if ('all' in node || 'any' in node) {
const members = 'all' in node ? node.all : node.any
const children = members.map(prune).filter((child): child is PredicateNode => child !== null)
if (children.length === 0) return null
return 'all' in node ? { all: children } : { any: children }
}
return live.has(node.field) || SYSTEM_COLUMN_FIELDS.has(node.field) ? node : null
}

const pruned = prune(predicate)
return pruned && ('all' in pruned || 'any' in pruned) ? pruned : null
}

/**
* Discriminates the v2 predicate tree from the legacy `$`-object on dual-grammar
* wire fields. Group-first, matching every other discrimination site.
Expand Down
36 changes: 31 additions & 5 deletions apps/sim/lib/table/views/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,35 @@ describe('pruneViewConfig', () => {
pruneViewConfig({ sort: [{ field: 'col_a', direction: 'desc' }] }, columns).sort
).toEqual([{ field: 'col_a', direction: 'desc' }])
})

it('prunes deleted columns and empty groups from nested view predicates', () => {
const config: TableViewConfig = {
filter: {
any: [
{ field: 'col_gone', op: 'eq', value: 'x' },
{
all: [
{ field: 'col_a', op: 'contains', value: 'A' },
{ any: [{ field: 'col_gone', op: 'eq', value: 'y' }] },
],
},
],
},
}
expect(pruneViewConfig(config, columns).filter).toEqual({
any: [{ all: [{ field: 'col_a', op: 'contains', value: 'A' }] }],
})
expect(
pruneViewConfig({ filter: { all: [{ field: 'col_gone', op: 'eq', value: 'x' }] } }, columns)
.filter
).toBeNull()
expect(
pruneViewConfig(
{ filter: { all: [{ field: 'createdAt', op: 'gte', value: '2026-01-01' }] } },
columns
).filter
).toEqual({ all: [{ field: 'createdAt', op: 'gte', value: '2026-01-01' }] })
})
})

/**
Expand Down Expand Up @@ -283,11 +312,8 @@ describe('view config column-reference normalization', () => {
})

/**
* A column delete leaves the referencing views behind, and `pruneViewConfig`
* deliberately does not prune a filter. The write must therefore let the
* already-stored reference through — otherwise the first save of anything else
* on that view (a sort change, a hidden-column change, the Save chip's whole
* config) 400s on a condition the user did not touch.
* A stale client may still carry a deleted filter column. The write permits
* references already present in storage so unrelated edits remain writable.
*/
it('lets a save carry forward a stale filter reference the view already stored', async () => {
const stale = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] }
Expand Down
32 changes: 16 additions & 16 deletions apps/sim/lib/table/views/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import { TableQueryValidationError } from '@/lib/table/errors'
import { signalTableViewsChanged } from '@/lib/table/events'
import type { DbTransaction } from '@/lib/table/planner'
import { filterRulesToPredicate, filterToRules } from '@/lib/table/query-builder/converters'
import {
filterRulesToPredicate,
filterToRules,
pruneViewPredicateForColumns,
} from '@/lib/table/query-builder/converters'
import {
SYSTEM_COLUMN_FIELDS,
validateStoragePredicate,
Expand Down Expand Up @@ -70,9 +74,8 @@ export class TableViewValidationError extends Error {
* every view on every column delete, stale ids are pruned here on read — the
* stored blob stays as-is and self-heals on the next save.
*
* `filter` is deliberately left untouched: pruning a predicate would silently
* widen the view's row set, which is worse than surfacing a filter the user can
* see and remove. The filter builder already renders a stale column id as-is.
* Deleted filter columns and empty groups are removed recursively so the view
* stays usable and its row query agrees with the visible filter controls.
*/
export function pruneViewConfig(
config: TableViewConfig,
Expand All @@ -81,6 +84,7 @@ export function pruneViewConfig(
const live = new Set(columns.map(getColumnId))
const pruned: TableViewConfig = { ...config }

if (config.filter) pruned.filter = pruneViewPredicateForColumns(config.filter, columns)
if (config.columnOrder) pruned.columnOrder = config.columnOrder.filter((id) => live.has(id))
if (config.pinnedColumns) pruned.pinnedColumns = config.pinnedColumns.filter((id) => live.has(id))
if (config.hiddenColumns) pruned.hiddenColumns = config.hiddenColumns.filter((id) => live.has(id))
Expand Down Expand Up @@ -193,12 +197,9 @@ function tolerantColumns(
* cannot carry a dangling layout ref forward: the read it echoes is pruned.
*
* `carriedForward` names the references that are exempt from that refusal.
* Deleting a column leaves every view that filtered on it dangling —
* `pruneViewConfig` deliberately does not prune a filter — so without the
* exemption the filter becomes unwritable: changing one of its other conditions
* autosaves the whole predicate and would be refused over the dangling condition
* the user did not touch, with no way to save its eventual removal. The v2
* surface exempts only what the STORED config
* A stale client can carry a filter column deleted since its last read, so
* the exemption keeps unrelated edits writable until that client refetches the
* pruned config. The v2 surface exempts only what the STORED config
* already held, so a reference the caller INTRODUCES is refused; a first-party
* caller exempts its own refs too, which is the behavior the grid has always
* had — see {@link CreateTableViewData.strictRefs}.
Expand Down Expand Up @@ -344,7 +345,7 @@ function toTableView(row: typeof tableViews.$inferSelect, columns: ColumnDefinit
}
}

/** Every view on a table, oldest first, with stale column references pruned. */
/** Every view on a table, alphabetically by name, with stale column references pruned. */
export async function listTableViews(
tableId: string,
columns: ColumnDefinition[],
Expand All @@ -359,7 +360,7 @@ export async function listTableViews(
workspaceId ? eq(tableViews.workspaceId, workspaceId) : undefined
)
)
.orderBy(asc(tableViews.createdAt), asc(tableViews.id))
.orderBy(asc(sql`lower(${tableViews.name})`), asc(tableViews.createdAt), asc(tableViews.id))

return rows.map((row) => toTableView(row, columns))
}
Expand Down Expand Up @@ -429,10 +430,9 @@ export interface CreateTableViewData {
* in this request and can be told which reference was wrong.
*
* Absent — the first-party grid, which does not author these refs so much as
* carry them: a view filtered on a since-deleted column keeps the dangling
* leaf through every read (`pruneViewConfig` spares filters) and hands it
* straight back on the next autosave. Refusing it would reject a config the
* first-party grid already accepted, over a condition the user never touched.
* carry them: a stale client may hand a since-deleted column back on its
* next autosave before refetching the pruned config. Refusing it would reject
* a config the grid already accepted, over a condition the user never touched.
*/
strictRefs?: boolean
}
Expand Down
Loading