diff --git a/apps/sim/.gitignore b/apps/sim/.gitignore index e90bb4dc00d..cd230779797 100644 --- a/apps/sim/.gitignore +++ b/apps/sim/.gitignore @@ -11,6 +11,10 @@ bun-debug.log* # testing /coverage +/e2e/.cache/ +/e2e/.runs/ +/playwright-report/ +/test-results/ # next.js /.next/ @@ -45,4 +49,4 @@ next-env.d.ts # Uploads /uploads -.trigger \ No newline at end of file +.trigger 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 b2215ccd221..ddd74be1202 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 @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index f6333ae46f8..92719a22759 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -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' @@ -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, diff --git a/apps/sim/lib/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts index dc6afdc913f..3d0bd9aac8a 100644 --- a/apps/sim/lib/table/query-builder/converters.ts +++ b/apps/sim/lib/table/query-builder/converters.ts @@ -4,13 +4,13 @@ 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, @@ -18,6 +18,7 @@ import type { FilterRule, JsonValue, Predicate, + PredicateNode, Sort, SortRule, SortSpec, @@ -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. diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index c0c666ac46c..64968d4e572 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -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' }] }) + }) }) /** @@ -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' }] } diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index ae05b60f578..56c9a54973f 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -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, @@ -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, @@ -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)) @@ -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}. @@ -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[], @@ -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)) } @@ -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 }