From 658c7f376055250d55c98cef806f05d2abffe3a1 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:29:35 -0700 Subject: [PATCH 01/13] feat(tables): improve view and filter controls --- .../columns-menu/columns-menu.test.tsx | 55 ++++++ .../components/columns-menu/columns-menu.tsx | 51 +++--- .../components/table-filter/index.ts | 2 +- .../table-filter/table-filter.test.tsx | 162 ++++++++++++++++++ .../components/table-filter/table-filter.tsx | 143 +++++++++------- .../components/views-menu/views-menu.test.tsx | 19 +- .../components/views-menu/views-menu.tsx | 13 +- .../[workspaceId]/tables/[tableId]/table.tsx | 33 +++- apps/sim/hooks/queries/tables.test.ts | 32 ++++ apps/sim/hooks/queries/tables.ts | 3 + 10 files changed, 404 insertions(+), 109 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx new file mode 100644 index 00000000000..28f246a05a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx @@ -0,0 +1,55 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ColumnsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('ColumnsMenu', () => { + it('uses the app menu typography and icon sizing shared by Sort', () => { + const onChange = vi.fn() + act(() => { + root.render( + + ) + }) + act(() => { + container + .querySelector('button') + ?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + }) + + const item = document.body.querySelector('[role="menuitem"]') + expect(item).not.toBeNull() + expect(item).toHaveClass('text-small') + expect(item?.querySelector('svg')).toHaveClass('size-[14px]') + + act(() => item?.click()) + expect(onChange).toHaveBeenCalledWith(['col-name']) + expect(document.body.querySelector('[role="menuitem"]')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx index 0409ee2cf6a..a7bbb0f9f95 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx @@ -4,12 +4,10 @@ import { memo, useMemo, useState } from 'react' import { Chip, cn, - POPOVER_ANIMATION_CLASSES, - Popover, - PopoverContent, - PopoverItem, - PopoverSection, - PopoverTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from '@sim/emcn' import { Columns3, Eye, EyeOff } from '@sim/emcn/icons' import type { ColumnDefinition, WorkflowGroup } from '@/lib/table' @@ -78,30 +76,18 @@ export const ColumnsMenu = memo(function ColumnsMenu({ const hiddenCount = hiddenColumns.length return ( - - + + {/* `active` alone signals that something is hidden — the label stays fixed so the bar doesn't reflow as columns are toggled. */} 0} leftIcon={Columns3}> Columns - - + - - Columns -
{plain.map((col) => { const id = getColumnId(col) @@ -144,8 +130,8 @@ export const ColumnsMenu = memo(function ColumnsMenu({ ) })}
-
-
+ + ) }) @@ -164,14 +150,17 @@ function ColumnToggleRow({ label, visible, partial, indented, onToggle }: Column const showing = visible || partial const Icon = showing ? Eye : EyeOff return ( - onToggle(!visible)} - className={cn('h-7 items-center gap-1.5 px-1.5 py-0 text-xs', indented && 'pl-5')} + { + event.preventDefault() + onToggle(!visible) + }} + className={cn(indented && 'pl-7')} > {label} - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts index 8cd08769fea..50e871c271a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts @@ -1 +1 @@ -export { TableFilter } from './table-filter' +export { TableFilter, type TableFilterHandle } from './table-filter' 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 new file mode 100644 index 00000000000..1e9baccc51d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.test.tsx @@ -0,0 +1,162 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef, type Ref } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ColumnDefinition, TablePredicate } from '@/lib/table' +import { + FILTER_DEBOUNCE_MS, + TableFilter, + type TableFilterHandle, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter' + +const COLUMNS: ColumnDefinition[] = [{ id: 'col-name', name: 'Name', type: 'string' }] + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() +}) + +function renderFilter( + onChange: (filter: TablePredicate | null) => void, + filter: TablePredicate | null = null, + ref?: Ref +) { + act(() => { + root.render() + }) +} + +describe('TableFilter', () => { + it('applies text filters after a short typing delay', () => { + const onApply = vi.fn() + renderFilter(onApply) + const input = container.querySelector('input[placeholder="Enter a value"]') + expect(input).not.toBeNull() + + act(() => { + if (!input) return + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + expect(onApply).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1)) + expect(onApply).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(onApply).toHaveBeenCalledWith({ + all: [{ field: 'col-name', op: 'eq', value: 'Ada' }], + }) + }) + + it('uses fixed AND conjunctions without apply or clear actions', () => { + renderFilter(vi.fn()) + const addFilter = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Add filter') + ) + + act(() => addFilter?.click()) + + const conjunction = Array.from(container.querySelectorAll('*')).find( + (element) => element.textContent?.trim() === 'and' + ) + expect(conjunction).toBeDefined() + expect(conjunction?.closest('button')).toBeNull() + expect(container.textContent).not.toContain('Apply filter') + expect(container.textContent).not.toContain('Clear filters') + }) + + it('flushes the pending filter when the panel closes before the delay', () => { + const onChange = vi.fn() + const filterRef = createRef() + renderFilter(onChange, null, filterRef) + const input = container.querySelector('input[placeholder="Enter a value"]') + + act(() => { + if (!input) return + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + act(() => { + filterRef.current?.flush() + }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ + all: [{ field: 'col-name', op: 'eq', value: 'Ada' }], + }) + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('cancels the previous debounce when typing continues', () => { + const onChange = vi.fn() + renderFilter(onChange) + const input = container.querySelector('input[placeholder="Enter a value"]') + const setInput = (value: string) => { + if (!input) return + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + } + + act(() => setInput('Ada')) + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1)) + act(() => setInput('Grace')) + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ + all: [{ field: 'col-name', op: 'eq', value: 'Grace' }], + }) + }) + + it('clears the active filter when its last rule is removed', () => { + const onChange = vi.fn() + renderFilter(onChange, { + all: [{ field: 'col-name', op: 'eq', value: 'Ada' }], + }) + + const removeButton = container.querySelector( + 'button[aria-label="Remove filter"]' + ) + act(() => removeButton?.click()) + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) + + expect(onChange).toHaveBeenCalledWith(null) + expect( + container.querySelector('input[placeholder="Enter a value"]')?.value + ).toBe('') + }) + + it('normalizes a previously saved OR filter to AND', () => { + const onChange = vi.fn() + renderFilter(onChange, { + any: [ + { all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] }, + { all: [{ field: 'col-name', op: 'eq', value: 'Grace' }] }, + ], + }) + + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) + + expect(onChange).toHaveBeenCalledWith({ + all: [ + { field: 'col-name', op: 'eq', value: 'Ada' }, + { field: 'col-name', op: 'eq', value: 'Grace' }, + ], + }) + }) +}) 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 7ce7cc200f3..b3bc0887e3d 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,6 +1,15 @@ 'use client' -import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import { Button, ChipDropdown, ChipInput } from '@sim/emcn' import { Plus, X } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' @@ -24,6 +33,8 @@ const MULTI_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) => MULTI_SELECT_FILTER_OPERATORS.has(o.value) ) +export const FILTER_DEBOUNCE_MS = 250 + function selectFilterOperators(column: ColumnDefinition | undefined): Set { return column?.multiple ? MULTI_SELECT_FILTER_OPERATORS : SINGLE_SELECT_FILTER_OPERATORS } @@ -31,18 +42,34 @@ function selectFilterOperators(column: ColumnDefinition | undefined): Set void - onClose: () => void + onChange: (filter: TablePredicate | null) => void +} + +export interface TableFilterHandle { + flush: () => void +} + +interface PendingFilter { + filter: TablePredicate | null + signature: string } -export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) { +export const TableFilter = forwardRef(function TableFilter( + { columns, filter, onChange }, + ref +) { + const lastAppliedFilterRef = useRef(JSON.stringify(filter)) + const onChangeRef = useRef(onChange) + const pendingFilterRef = useRef(null) + const timeoutRef = useRef | null>(null) const [rules, setRules] = useState(() => { - const fromFilter = predicateToFilterRules(filter) + const fromFilter = predicateToFilterRules(filter).map((rule) => ({ + ...rule, + logicalOperator: 'and' as const, + })) return fromFilter.length > 0 ? fromFilter : [createRule(columns)] }) - - const rulesRef = useRef(rules) - rulesRef.current = rules + onChangeRef.current = onChange // `value` is the filter field key (column id); `label` is what the user sees. const columnOptions = useMemo( @@ -61,16 +88,12 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr const handleRemove = useCallback( (id: string) => { - const next = rulesRef.current.filter((r) => r.id !== id) - if (next.length === 0) { - onApply(null) - onClose() - setRules([createRule(columns)]) - } else { - setRules(next) - } + setRules((prev) => { + const next = prev.filter((rule) => rule.id !== id) + return next.length > 0 ? next : [createRule(columns)] + }) }, - [columns, onApply, onClose] + [columns] ) const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => { @@ -103,25 +126,44 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr [columnById] ) - const handleToggleLogical = useCallback((id: string) => { - setRules((prev) => - prev.map((r) => - r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r - ) - ) + const flush = useCallback(() => { + const pending = pendingFilterRef.current + if (!pending) return + + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = null + pendingFilterRef.current = null + lastAppliedFilterRef.current = pending.signature + onChangeRef.current(pending.filter) }, []) - const handleApply = useCallback(() => { - const validRules = rulesRef.current.filter( - (r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator)) + useImperativeHandle(ref, () => ({ flush }), [flush]) + + useEffect(() => { + const validRules = rules.filter( + (rule) => rule.column && (rule.value || VALUELESS_OPERATORS.has(rule.operator)) ) - onApply(filterRulesToPredicate(validRules, columns)) - }, [columns, onApply]) + const nextFilter = filterRulesToPredicate(validRules, columns) + const signature = JSON.stringify(nextFilter) + if (signature === lastAppliedFilterRef.current) { + pendingFilterRef.current = null + return + } + + const pending = { filter: nextFilter, signature } + pendingFilterRef.current = pending + const timeout = setTimeout(() => { + if (pendingFilterRef.current !== pending) return + timeoutRef.current = null + flush() + }, FILTER_DEBOUNCE_MS) + timeoutRef.current = timeout - const handleClear = useCallback(() => { - setRules([createRule(columns)]) - onApply(null) - }, [columns, onApply]) + return () => { + clearTimeout(timeout) + if (timeoutRef.current === timeout) timeoutRef.current = null + } + }, [rules, columns, flush]) return (
@@ -136,12 +178,10 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr onUpdate={handleUpdate} onColumnChange={handleColumnChange} onRemove={handleRemove} - onApply={handleApply} - onToggleLogical={handleToggleLogical} /> ))} -
+
-
- {filter !== null && ( - - )} - -
) -} +}) interface FilterRuleRowProps { rule: FilterRule @@ -180,8 +205,6 @@ interface FilterRuleRowProps { onUpdate: (id: string, field: keyof FilterRule, value: string) => void onColumnChange: (id: string, columnId: string) => void onRemove: (id: string) => void - onApply: () => void - onToggleLogical: (id: string) => void } const FilterRuleRow = memo(function FilterRuleRow({ @@ -192,8 +215,6 @@ const FilterRuleRow = memo(function FilterRuleRow({ onUpdate, onColumnChange, onRemove, - onApply, - onToggleLogical, }: FilterRuleRowProps) { // Keep a stale column id selectable/visible (e.g. after the column was // removed) instead of falling back to the placeholder while the rule still @@ -226,12 +247,9 @@ const FilterRuleRow = memo(function FilterRuleRow({ {isFirst ? ( Where ) : ( - + + and + )} onUpdate(rule.id, 'value', e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') onApply() - }} placeholder='Enter a value' className='flex-1' /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx index fc9507426b5..d1e5aace702 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx @@ -19,10 +19,10 @@ const DEFAULT_VIEW: TableViewWire = { updatedAt: new Date('2026-08-15T01:00:00.000Z'), } -const SAVED_VIEW: TableViewWire = { +const SECOND_VIEW: TableViewWire = { ...DEFAULT_VIEW, - id: 'view-saved', - name: 'Saved', + id: 'view-second', + name: 'Second view', isDefault: false, } @@ -33,6 +33,7 @@ function renderMenu(views: TableViewWire[], activeViewId: string | null): string activeViewId={activeViewId} onSelect={vi.fn()} onRename={vi.fn()} + onSetDefault={vi.fn()} onDelete={vi.fn()} onNewView={vi.fn()} canEdit @@ -55,18 +56,20 @@ describe('ViewsMenu', () => { expect(markup).not.toContain('>View<') }) - it('only offers deletion for non-default views', () => { + it('only offers set-default and delete actions for non-default views', () => { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) + const onSetDefault = vi.fn() act(() => { root.render( { act(() => container.querySelector('button[aria-label="Views"]')?.click()) expect(document.body.querySelectorAll('button[aria-label="Delete"]')).toHaveLength(1) + const actions = document.body.querySelectorAll( + 'button[aria-label="Set as default"]' + ) + expect(actions).toHaveLength(1) + act(() => actions[0]?.click()) + expect(onSetDefault).toHaveBeenCalledWith(SECOND_VIEW.id) act(() => root.unmount()) container.remove() 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 14b833f8108..24c3f506a16 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 @@ -13,7 +13,7 @@ import { PopoverItem, PopoverSection, } from '@sim/emcn' -import { Check, Pencil, Plus, Trash } from '@sim/emcn/icons' +import { Check, Pencil, Pin, Plus, Trash } from '@sim/emcn/icons' import type { TableViewWire } from '@/lib/api/contracts/tables' import { resolveTableViewSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state' @@ -34,6 +34,7 @@ interface ViewsMenuProps { activeViewId: string | null onSelect: (viewId: string | null) => void onRename: (viewId: string) => void + onSetDefault: (viewId: string) => void onDelete: (viewId: string) => void /** Starts a blank view — named first, configured after. */ onNewView: () => void @@ -53,6 +54,7 @@ export const ViewsMenu = memo(function ViewsMenu({ activeViewId, onSelect, onRename, + onSetDefault, onDelete, onNewView, canEdit, @@ -151,6 +153,15 @@ export const ViewsMenu = memo(function ViewsMenu({ actions={ canEdit ? [ + ...(view.isDefault + ? [] + : [ + { + icon: Pin, + label: 'Set as default', + onClick: () => runAndClose(() => onSetDefault(view.id)), + }, + ]), { icon: Pencil, label: 'Rename', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index f368f06908d..dd507d71910 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -82,6 +82,7 @@ import { type SelectionSnapshot, TableActionBar, TableFilter, + type TableFilterHandle, TableGrid, ViewsMenu, type WorkflowConfig, @@ -248,6 +249,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) @@ -726,6 +728,7 @@ export function Table({ const handleSelectView = useCallback( (viewId: string | null) => { preservedViewStateRef.current = null + tableFilterRef.current?.flush() setTableParams({ view: viewId ?? ALL_VIEW_PARAM }) }, [setTableParams] @@ -735,6 +738,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' }) }, []) @@ -1179,10 +1191,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) @@ -1421,7 +1436,10 @@ export function Table({ // 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), []) + const handleToggleFilter = useCallback(() => { + if (filterOpen) tableFilterRef.current?.flush() + setFilterOpen(!filterOpen) + }, [filterOpen]) const filterConfig = useMemo( () => ({ mode: 'toggle' as const, @@ -1496,6 +1514,7 @@ export function Table({ activeViewId={activeView?.id ?? null} onSelect={handleSelectView} onRename={handleRenameView} + onSetDefault={handleSetDefaultView} onDelete={handleDeleteView} onNewView={handleNewView} canEdit={userPermissions.canEdit} @@ -1516,11 +1535,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,37 @@ 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, + ]) + }) }) describe('useDeleteColumn optimistic update', () => { diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index fbe80d0abda..83d2fdb7e8b 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1581,6 +1581,9 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext) onSuccess: (view) => { queryClient.setQueryData(tableKeys.views(tableId), (prev) => prev?.map((existing) => { + if (view.isDefault && existing.id !== view.id && existing.isDefault) { + return { ...existing, isDefault: false } + } 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 From 8f3f527727ac8fea5bf44958a3f4a33751e8472d Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:58:17 -0700 Subject: [PATCH 02/13] fix(tables): keep menu actions open --- .../resource-options.test.tsx | 31 +++++ .../resource-options/resource-options.tsx | 10 +- .../columns-menu/columns-menu.test.tsx | 52 +++++--- .../components/views-menu/views-menu.test.tsx | 114 ++++++++++++++++-- .../components/views-menu/views-menu.tsx | 73 ++++++----- 5 files changed, 221 insertions(+), 59 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 7a8e6682153..bb825f886c5 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 @@ -67,4 +67,35 @@ describe('SortDropdown', () => { expect(item?.querySelector('[data-testid="column-icon"]')).not.toBeNull() expect(item?.querySelectorAll('svg')).toHaveLength(2) }) + + it('keeps the popup open while changing or clearing the sort', () => { + const onOpenChange = vi.fn() + const onSort = vi.fn() + const onClear = vi.fn() + act(() => { + root.render( + + ) + }) + + const items = document.body.querySelectorAll('[role="menuitem"]') + expect(items).toHaveLength(2) + + act(() => items[1]?.click()) + expect(onSort).toHaveBeenCalledWith('name', 'desc') + + act(() => items[0]?.click()) + expect(onClear).toHaveBeenCalledOnce() + expect(onOpenChange).not.toHaveBeenCalledWith(false) + expect(document.body.querySelectorAll('[role="menuitem"]')).toHaveLength(2) + }) }) 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 e8dc0876874..4caae455b2b 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 @@ -299,7 +299,12 @@ export const SortDropdown = memo(function SortDropdown({ > {active && onClear && ( <> - + { + event.preventDefault() + onClear() + }} + > Clear sort @@ -314,7 +319,8 @@ export const SortDropdown = memo(function SortDropdown({ return ( { + onSelect={(event) => { + event.preventDefault() if (isActive) { onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc') } else { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx index 28f246a05a4..3c812909631 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' +import { act, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ColumnsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu' @@ -9,6 +9,26 @@ import { ColumnsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/comp let container: HTMLDivElement let root: Root +function ColumnsMenuHarness({ onChange }: { onChange: (hiddenColumns: string[]) => void }) { + const [hiddenColumns, setHiddenColumns] = useState([]) + + return ( + { + setHiddenColumns(nextHiddenColumns) + onChange(nextHiddenColumns) + }} + /> + ) +} + beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') @@ -22,20 +42,10 @@ afterEach(() => { }) describe('ColumnsMenu', () => { - it('uses the app menu typography and icon sizing shared by Sort', () => { + it('uses the app menu styling and stays open across column changes', () => { const onChange = vi.fn() act(() => { - root.render( - - ) + root.render() }) act(() => { container @@ -43,13 +53,17 @@ describe('ColumnsMenu', () => { ?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) }) - const item = document.body.querySelector('[role="menuitem"]') - expect(item).not.toBeNull() - expect(item).toHaveClass('text-small') - expect(item?.querySelector('svg')).toHaveClass('size-[14px]') + const items = document.body.querySelectorAll('[role="menuitem"]') + expect(items).toHaveLength(3) + expect(items[0]).toHaveClass('text-small') + expect(items[0]?.querySelector('svg')).toHaveClass('size-[14px]') - act(() => item?.click()) + act(() => items[0]?.click()) expect(onChange).toHaveBeenCalledWith(['col-name']) - expect(document.body.querySelector('[role="menuitem"]')).not.toBeNull() + + const remainingItems = document.body.querySelectorAll('[role="menuitem"]') + expect(remainingItems).toHaveLength(3) + act(() => remainingItems[1]?.click()) + expect(onChange).toHaveBeenLastCalledWith(['col-name', 'col-email']) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx index d1e5aace702..f1c7cfe3dcc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TableViewWire } from '@/lib/api/contracts/tables' import { ViewsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu' @@ -26,6 +26,15 @@ const SECOND_VIEW: TableViewWire = { isDefault: false, } +const PRIMARY_VIEW: TableViewWire = { + ...DEFAULT_VIEW, + name: 'Primary view', +} + +afterEach(() => { + vi.useRealTimers() +}) + function renderMenu(views: TableViewWire[], activeViewId: string | null): string { return renderToStaticMarkup( { expect(markup).not.toContain('>View<') }) - it('only offers set-default and delete actions for non-default views', () => { + it('shows filled and outline pins without a Default badge and keeps the menu open', () => { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) @@ -65,8 +74,8 @@ describe('ViewsMenu', () => { act(() => { root.render( { act(() => container.querySelector('button[aria-label="Views"]')?.click()) expect(document.body.querySelectorAll('button[aria-label="Delete"]')).toHaveLength(1) - const actions = document.body.querySelectorAll( + const defaultPin = document.body.querySelector( + 'button[aria-label="Current default view"]' + ) + const setDefaultPin = document.body.querySelector( 'button[aria-label="Set as default"]' ) - expect(actions).toHaveLength(1) - act(() => actions[0]?.click()) + + expect(defaultPin?.querySelector('svg')).toHaveClass('fill-current') + expect(setDefaultPin?.querySelector('svg')).not.toHaveClass('fill-current') + expect(document.body).not.toHaveTextContent('Default') + + act(() => setDefaultPin?.click()) expect(onSetDefault).toHaveBeenCalledWith(SECOND_VIEW.id) + expect(document.body).toHaveTextContent('New view') + + act(() => root.unmount()) + container.remove() + }) + + it('keeps the menu open when keyboard focus moves from the trigger to the default pin', () => { + vi.useFakeTimers() + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => { + root.render( + + ) + }) + + const trigger = container.querySelector('button[aria-label="Views"]') + act(() => trigger?.focus()) + + const setDefaultPin = document.body.querySelector( + 'button[aria-label="Set as default"]' + ) + expect(setDefaultPin).not.toBeNull() + act(() => { + setDefaultPin?.focus() + vi.advanceTimersByTime(121) + }) + + expect(document.activeElement).toBe(setDefaultPin) + expect(document.body).toHaveTextContent('New view') + expect(document.body.querySelector('[data-native-surface-overlay]')).not.toBeNull() + + act(() => root.unmount()) + container.remove() + }) + + it('shows disabled pins without closing the menu for read-only members', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onSetDefault = vi.fn() + + act(() => { + root.render( + + ) + }) + act(() => container.querySelector('button[aria-label="Views"]')?.click()) + + const defaultPin = document.body.querySelector( + 'button[aria-label="Current default view"]' + ) + const setDefaultPin = document.body.querySelector( + 'button[aria-label="Set as default"]' + ) + + expect(defaultPin?.querySelector('svg')).toHaveClass('fill-current') + expect(setDefaultPin?.querySelector('svg')).not.toHaveClass('fill-current') + expect(setDefaultPin).toBeDisabled() + + act(() => setDefaultPin?.click()) + + expect(onSetDefault).not.toHaveBeenCalled() + expect(document.body.querySelector('[data-native-surface-overlay]')).not.toBeNull() act(() => root.unmount()) container.remove() 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 24c3f506a16..2deb28317d4 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 @@ -23,11 +23,6 @@ export const ALL_ROWS_VIEW_LABEL = 'All' /** Matches the breadcrumb location popover's hover-intent grace period. */ const POPOVER_CLOSE_DELAY_MS = 120 -/** Rendered width of one action button (`p-1` + `size-3` glyph) plus its `gap-0.5`. - * The row reserves `actions.length` of these, so keep it in step with the button - * classes below — the overlay is absolutely positioned and can't size the spacer. */ -const VIEW_ACTION_SLOT_PX = 22 - interface ViewsMenuProps { views: TableViewWire[] /** `null` selects the legacy "All" state while a table awaits backfill. */ @@ -131,6 +126,7 @@ export const ViewsMenu = memo(function ViewsMenu({ )} onMouseEnter={openPopover} onMouseLeave={scheduleClose} + onFocusCapture={cancelScheduledClose} > Views @@ -148,20 +144,20 @@ export const ViewsMenu = memo(function ViewsMenu({ key={view.id} label={view.name} isActive={view.id === activeViewId} - isDefault={view.isDefault} onSelect={() => runAndClose(() => onSelect(view.id))} + defaultState={{ + isDefault: view.isDefault, + onSetDefault: + canEdit && !view.isDefault + ? () => { + cancelScheduledClose() + onSetDefault(view.id) + } + : undefined, + }} actions={ canEdit ? [ - ...(view.isDefault - ? [] - : [ - { - icon: Pin, - label: 'Set as default', - onClick: () => runAndClose(() => onSetDefault(view.id)), - }, - ]), { icon: Pencil, label: 'Rename', @@ -207,11 +203,16 @@ interface ViewRowAction { onClick: () => void } +interface ViewRowDefaultState { + isDefault: boolean + onSetDefault?: () => void +} + interface ViewRowProps { label: string isActive: boolean - isDefault?: boolean onSelect: () => void + defaultState?: ViewRowDefaultState actions?: ViewRowAction[] } @@ -221,7 +222,9 @@ interface ViewRowProps { * on hover via opacity, not `display`) so the name never reflows or sits * underneath them. */ -function ViewRow({ label, isActive, isDefault, onSelect, actions }: ViewRowProps) { +function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowProps) { + const actionCount = (actions?.length ?? 0) + (defaultState ? 1 : 0) + return (
} {label} - {isDefault && ( - - Default - - )} - {actions && ( + {actionCount > 0 && ( )} - {actions && ( -
- {actions.map((action) => ( + {actionCount > 0 && ( +
+ {actions?.map((action) => ( ))} + {defaultState && ( + + )}
)}
From 9c2af168d5d5d774a3239da62237ad7bb4efcb7b Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:47:47 -0700 Subject: [PATCH 03/13] fix(tables): guard view autosave against echo remounts and stale responses Co-Authored-By: Claude Fable 5 --- .../[workspaceId]/tables/[tableId]/table.tsx | 9 +++-- apps/sim/hooks/queries/tables.test.ts | 38 +++++++++++++++++++ apps/sim/hooks/queries/tables.ts | 23 ++++++----- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index dd507d71910..a8008228ef3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1208,7 +1208,9 @@ 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[]) => { const next = withCellValueFilter(effectiveFilter, conditions) @@ -1434,8 +1436,9 @@ 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. + // 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) diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts index d0e63a31d86..0cdf1ea939d 100644 --- a/apps/sim/hooks/queries/tables.test.ts +++ b/apps/sim/hooks/queries/tables.test.ts @@ -137,6 +137,44 @@ describe('useUpdateTableView autosave ordering', () => { 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 83d2fdb7e8b..eff2564ae64 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1579,19 +1579,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) => { + 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 } } - 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 + return existing.id === view.id ? view : existing }) - ) + }) }, onSettled: () => { // A scoped mutation only needs the database write ahead of the next From 42dc097d62cf4ebe5fec469b82691db6cddbbe73 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:47:48 -0700 Subject: [PATCH 04/13] fix(tables): keep and/or filter toggles, autosave only real edits Co-Authored-By: Claude Fable 5 --- .../table-filter/table-filter.test.tsx | 44 +++++++++++++-- .../components/table-filter/table-filter.tsx | 54 ++++++++++++++----- .../sim/lib/table/query-builder/converters.ts | 11 +++- 3 files changed, 89 insertions(+), 20 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 1e9baccc51d..d304202a49c 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 @@ -62,7 +62,7 @@ describe('TableFilter', () => { }) }) - it('uses fixed AND conjunctions without apply or clear actions', () => { + it('offers a toggleable conjunction without apply or clear actions', () => { renderFilter(vi.fn()) const addFilter = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Add filter') @@ -70,11 +70,14 @@ describe('TableFilter', () => { act(() => addFilter?.click()) - const conjunction = Array.from(container.querySelectorAll('*')).find( - (element) => element.textContent?.trim() === 'and' + const conjunction = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'and' ) expect(conjunction).toBeDefined() - expect(conjunction?.closest('button')).toBeNull() + + act(() => conjunction?.click()) + expect(conjunction?.textContent?.trim()).toBe('or') + expect(container.textContent).not.toContain('Apply filter') expect(container.textContent).not.toContain('Clear filters') }) @@ -141,7 +144,34 @@ describe('TableFilter', () => { ).toBe('') }) - it('normalizes a previously saved OR filter to AND', () => { + it('preserves saved isNull conditions instead of dropping them', () => { + const onChange = vi.fn() + renderFilter(onChange, { all: [{ field: 'col-name', op: 'isNull' }] }) + + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('loads a saved OR filter verbatim without an unsolicited autosave', () => { + const onChange = vi.fn() + renderFilter(onChange, { + any: [ + { all: [{ field: 'col-name', op: 'eq', value: 'Ada' }] }, + { all: [{ field: 'col-name', op: 'eq', value: 'Grace' }] }, + ], + }) + + const orToggle = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'or' + ) + expect(orToggle).toBeDefined() + + act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) + expect(onChange).not.toHaveBeenCalled() + }) + + it('merges the OR groups when the conjunction is toggled back to and', () => { const onChange = vi.fn() renderFilter(onChange, { any: [ @@ -150,6 +180,10 @@ describe('TableFilter', () => { ], }) + const orToggle = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'or' + ) + act(() => orToggle?.click()) act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) expect(onChange).toHaveBeenCalledWith({ 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 b3bc0887e3d..1333725d391 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 @@ -19,11 +19,11 @@ import { COMPARISON_OPERATORS, MULTI_SELECT_FILTER_OPERATORS, SINGLE_SELECT_FILTER_OPERATORS, - VALUELESS_OPERATORS, } from '@/lib/table/query-builder/constants' import { filterRulesToPredicate, predicateToFilterRules, + VALUELESS_OPS, } from '@/lib/table/query-builder/converters' const SINGLE_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) => @@ -39,6 +39,17 @@ function selectFilterOperators(column: ColumnDefinition | undefined): Set rule.column && (rule.value || VALUELESS_OPS.has(rule.operator)) + ) + return filterRulesToPredicate(validRules, columns) +} + interface TableFilterProps { columns: ColumnDefinition[] filter: TablePredicate | null @@ -58,17 +69,21 @@ export const TableFilter = forwardRef(funct { columns, filter, onChange }, ref ) { - const lastAppliedFilterRef = useRef(JSON.stringify(filter)) + const lastAppliedFilterRef = useRef(undefined) const onChangeRef = useRef(onChange) const pendingFilterRef = useRef(null) const timeoutRef = useRef | null>(null) const [rules, setRules] = useState(() => { - const fromFilter = predicateToFilterRules(filter).map((rule) => ({ - ...rule, - logicalOperator: 'and' as const, - })) + const fromFilter = predicateToFilterRules(filter) return fromFilter.length > 0 ? fromFilter : [createRule(columns)] }) + // Seed the "already applied" signature from the rules the panel actually + // renders, not the raw prop: a saved tree the flat builder cannot express + // (deeply nested groups, wire key order) round-trips differently, and seeding + // from the prop would schedule an unedited autosave of that lossy form the + // moment the panel opens. The normalized form persists only once the user + // really edits a rule. + lastAppliedFilterRef.current ??= JSON.stringify(toAppliedPredicate(rules, columns)) onChangeRef.current = onChange // `value` is the filter field key (column id); `label` is what the user sees. @@ -100,6 +115,14 @@ export const TableFilter = forwardRef(funct setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))) }, []) + const handleToggleLogical = useCallback((id: string) => { + setRules((prev) => + prev.map((r) => + r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r + ) + ) + }, []) + // Switching a rule's column across the select boundary changes what values and // operators are valid, so clear the value and coerce an unsupported operator // back to `eq` — otherwise a stale free-text value or a range operator would @@ -140,10 +163,7 @@ export const TableFilter = forwardRef(funct useImperativeHandle(ref, () => ({ flush }), [flush]) useEffect(() => { - const validRules = rules.filter( - (rule) => rule.column && (rule.value || VALUELESS_OPERATORS.has(rule.operator)) - ) - const nextFilter = filterRulesToPredicate(validRules, columns) + const nextFilter = toAppliedPredicate(rules, columns) const signature = JSON.stringify(nextFilter) if (signature === lastAppliedFilterRef.current) { pendingFilterRef.current = null @@ -178,6 +198,7 @@ export const TableFilter = forwardRef(funct onUpdate={handleUpdate} onColumnChange={handleColumnChange} onRemove={handleRemove} + onToggleLogical={handleToggleLogical} /> ))} @@ -205,6 +226,7 @@ interface FilterRuleRowProps { onUpdate: (id: string, field: keyof FilterRule, value: string) => void onColumnChange: (id: string, columnId: string) => void onRemove: (id: string) => void + onToggleLogical: (id: string) => void } const FilterRuleRow = memo(function FilterRuleRow({ @@ -215,6 +237,7 @@ const FilterRuleRow = memo(function FilterRuleRow({ onUpdate, onColumnChange, onRemove, + onToggleLogical, }: FilterRuleRowProps) { // Keep a stale column id selectable/visible (e.g. after the column was // removed) instead of falling back to the placeholder while the rule still @@ -247,9 +270,12 @@ const FilterRuleRow = memo(function FilterRuleRow({ {isFirst ? ( Where ) : ( - - and - + )} - {VALUELESS_OPERATORS.has(rule.operator) ? ( + {VALUELESS_OPS.has(rule.operator) ? (
) : isSelect ? ( (['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 From 78f4a5c0d3afcc94525e5a320fa605be3ad6a46e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:47:49 -0700 Subject: [PATCH 05/13] fix(tables): compute view-row action spacer and cover the default pin Co-Authored-By: Claude Fable 5 --- .../[tableId]/components/views-menu/views-menu.test.tsx | 4 ++++ .../tables/[tableId]/components/views-menu/views-menu.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx index f1c7cfe3dcc..c993a467e14 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx @@ -103,6 +103,10 @@ describe('ViewsMenu', () => { expect(onSetDefault).toHaveBeenCalledWith(SECOND_VIEW.id) expect(document.body).toHaveTextContent('New view') + expect(defaultPin).toBeDisabled() + act(() => defaultPin?.click()) + expect(onSetDefault).toHaveBeenCalledTimes(1) + act(() => root.unmount()) container.remove() }) 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 2deb28317d4..8dd2a268462 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 @@ -23,6 +23,11 @@ export const ALL_ROWS_VIEW_LABEL = 'All' /** Matches the breadcrumb location popover's hover-intent grace period. */ const POPOVER_CLOSE_DELAY_MS = 120 +/** Rendered width of one action button (`p-1` + `size-3` glyph) plus its `gap-0.5`. + * The row reserves `actionCount` of these, so keep it in step with the button + * classes below — the overlay is absolutely positioned and can't size the spacer. */ +const VIEW_ACTION_SLOT_PX = 22 + interface ViewsMenuProps { views: TableViewWire[] /** `null` selects the legacy "All" state while a table awaits backfill. */ @@ -239,7 +244,8 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr {actionCount > 0 && ( )} From 4906840512faa441c6cbc60241e31b93118e3288 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:14:32 -0700 Subject: [PATCH 06/13] feat(tables): apply filter text on enter or blur instead of a debounce Co-Authored-By: Claude Fable 5 --- .../components/table-filter/index.ts | 2 +- .../table-filter/table-filter.test.tsx | 132 +++++++----------- .../components/table-filter/table-filter.tsx | 118 +++++++--------- .../[workspaceId]/tables/[tableId]/table.tsx | 14 +- 4 files changed, 109 insertions(+), 157 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts index 50e871c271a..8cd08769fea 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts @@ -1 +1 @@ -export { TableFilter, type TableFilterHandle } from './table-filter' +export { TableFilter } from './table-filter' 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 d304202a49c..d619a6151a8 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 @@ -1,15 +1,11 @@ /** * @vitest-environment jsdom */ -import { act, createRef, type Ref } from 'react' +import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ColumnDefinition, TablePredicate } from '@/lib/table' -import { - FILTER_DEBOUNCE_MS, - TableFilter, - type TableFilterHandle, -} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter' +import { TableFilter } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter' const COLUMNS: ColumnDefinition[] = [{ id: 'col-name', name: 'Name', type: 'string' }] @@ -18,7 +14,6 @@ let root: Root beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true - vi.useFakeTimers() container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -27,41 +22,70 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()) container.remove() - vi.useRealTimers() }) function renderFilter( onChange: (filter: TablePredicate | null) => void, - filter: TablePredicate | null = null, - ref?: Ref + filter: TablePredicate | null = null ) { act(() => { - root.render() + root.render() }) } +function valueInput(): HTMLInputElement | null { + return container.querySelector('input[placeholder="Enter a value"]') +} + +function typeInto(input: HTMLInputElement | null, value: string) { + if (!input) return + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + describe('TableFilter', () => { - it('applies text filters after a short typing delay', () => { - const onApply = vi.fn() - renderFilter(onApply) - const input = container.querySelector('input[placeholder="Enter a value"]') + it('commits a typed value on blur, not per keystroke', () => { + const onChange = vi.fn() + renderFilter(onChange) + const input = valueInput() expect(input).not.toBeNull() - act(() => { - if (!input) return - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) + act(() => typeInto(input, 'Ada')) + expect(onChange).not.toHaveBeenCalled() - expect(onApply).not.toHaveBeenCalled() - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1)) - expect(onApply).not.toHaveBeenCalled() - act(() => vi.advanceTimersByTime(1)) - expect(onApply).toHaveBeenCalledWith({ + act(() => input?.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ all: [{ field: 'col-name', op: 'eq', value: 'Ada' }], }) }) + it('commits a typed value on Enter', () => { + const onChange = vi.fn() + renderFilter(onChange) + const input = valueInput() + + act(() => typeInto(input, 'Grace')) + act(() => input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ + all: [{ field: 'col-name', op: 'eq', value: 'Grace' }], + }) + }) + + it('does not re-commit an unchanged value on blur after Enter', () => { + const onChange = vi.fn() + renderFilter(onChange) + const input = valueInput() + + act(() => typeInto(input, 'Ada')) + act(() => input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + act(() => input?.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + it('offers a toggleable conjunction without apply or clear actions', () => { renderFilter(vi.fn()) const addFilter = Array.from(container.querySelectorAll('button')).find((button) => @@ -82,51 +106,7 @@ describe('TableFilter', () => { expect(container.textContent).not.toContain('Clear filters') }) - it('flushes the pending filter when the panel closes before the delay', () => { - const onChange = vi.fn() - const filterRef = createRef() - renderFilter(onChange, null, filterRef) - const input = container.querySelector('input[placeholder="Enter a value"]') - - act(() => { - if (!input) return - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'Ada') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - act(() => { - filterRef.current?.flush() - }) - - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith({ - all: [{ field: 'col-name', op: 'eq', value: 'Ada' }], - }) - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) - expect(onChange).toHaveBeenCalledTimes(1) - }) - - it('cancels the previous debounce when typing continues', () => { - const onChange = vi.fn() - renderFilter(onChange) - const input = container.querySelector('input[placeholder="Enter a value"]') - const setInput = (value: string) => { - if (!input) return - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) - input.dispatchEvent(new Event('input', { bubbles: true })) - } - - act(() => setInput('Ada')) - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS - 1)) - act(() => setInput('Grace')) - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) - - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith({ - all: [{ field: 'col-name', op: 'eq', value: 'Grace' }], - }) - }) - - it('clears the active filter when its last rule is removed', () => { + it('clears the active filter as soon as its last rule is removed', () => { const onChange = vi.fn() renderFilter(onChange, { all: [{ field: 'col-name', op: 'eq', value: 'Ada' }], @@ -136,20 +116,15 @@ describe('TableFilter', () => { 'button[aria-label="Remove filter"]' ) act(() => removeButton?.click()) - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) expect(onChange).toHaveBeenCalledWith(null) - expect( - container.querySelector('input[placeholder="Enter a value"]')?.value - ).toBe('') + expect(valueInput()?.value).toBe('') }) it('preserves saved isNull conditions instead of dropping them', () => { const onChange = vi.fn() renderFilter(onChange, { all: [{ field: 'col-name', op: 'isNull' }] }) - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) - expect(onChange).not.toHaveBeenCalled() }) @@ -166,12 +141,10 @@ describe('TableFilter', () => { (button) => button.textContent?.trim() === 'or' ) expect(orToggle).toBeDefined() - - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) expect(onChange).not.toHaveBeenCalled() }) - it('merges the OR groups when the conjunction is toggled back to and', () => { + it('merges the OR groups as soon as the conjunction is toggled back to and', () => { const onChange = vi.fn() renderFilter(onChange, { any: [ @@ -184,7 +157,6 @@ describe('TableFilter', () => { (button) => button.textContent?.trim() === 'or' ) act(() => orToggle?.click()) - act(() => vi.advanceTimersByTime(FILTER_DEBOUNCE_MS)) expect(onChange).toHaveBeenCalledWith({ all: [ 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 1333725d391..7e0e4512d92 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,15 +1,6 @@ 'use client' -import { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, ChipDropdown, ChipInput } from '@sim/emcn' import { Plus, X } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' @@ -33,8 +24,6 @@ const MULTI_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) => MULTI_SELECT_FILTER_OPERATORS.has(o.value) ) -export const FILTER_DEBOUNCE_MS = 250 - function selectFilterOperators(column: ColumnDefinition | undefined): Set { return column?.multiple ? MULTI_SELECT_FILTER_OPERATORS : SINGLE_SELECT_FILTER_OPERATORS } @@ -56,23 +45,9 @@ interface TableFilterProps { onChange: (filter: TablePredicate | null) => void } -export interface TableFilterHandle { - flush: () => void -} - -interface PendingFilter { - filter: TablePredicate | null - signature: string -} - -export const TableFilter = forwardRef(function TableFilter( - { columns, filter, onChange }, - ref -) { +export function TableFilter({ columns, filter, onChange }: TableFilterProps) { const lastAppliedFilterRef = useRef(undefined) const onChangeRef = useRef(onChange) - const pendingFilterRef = useRef(null) - const timeoutRef = useRef | null>(null) const [rules, setRules] = useState(() => { const fromFilter = predicateToFilterRules(filter) return fromFilter.length > 0 ? fromFilter : [createRule(columns)] @@ -80,7 +55,7 @@ export const TableFilter = forwardRef(funct // Seed the "already applied" signature from the rules the panel actually // renders, not the raw prop: a saved tree the flat builder cannot express // (deeply nested groups, wire key order) round-trips differently, and seeding - // from the prop would schedule an unedited autosave of that lossy form the + // from the prop would fire an unedited autosave of that lossy form the // moment the panel opens. The normalized form persists only once the user // really edits a rule. lastAppliedFilterRef.current ??= JSON.stringify(toAppliedPredicate(rules, columns)) @@ -149,41 +124,19 @@ export const TableFilter = forwardRef(funct [columnById] ) - const flush = useCallback(() => { - const pending = pendingFilterRef.current - if (!pending) return - - if (timeoutRef.current) clearTimeout(timeoutRef.current) - timeoutRef.current = null - pendingFilterRef.current = null - lastAppliedFilterRef.current = pending.signature - onChangeRef.current(pending.filter) - }, []) - - useImperativeHandle(ref, () => ({ flush }), [flush]) - + // Applies on every rules change. Rules only change on completed gestures — + // dropdown picks, row add/remove, conjunction toggles, and the value field's + // Enter/blur commit ({@link FilterValueInput} buffers keystrokes locally) — + // so nothing is ever pending and there is nothing to lose on unmount. The + // signature guard keeps no-op changes (a blank row added, an untouched + // reseed) from writing. useEffect(() => { const nextFilter = toAppliedPredicate(rules, columns) const signature = JSON.stringify(nextFilter) - if (signature === lastAppliedFilterRef.current) { - pendingFilterRef.current = null - return - } - - const pending = { filter: nextFilter, signature } - pendingFilterRef.current = pending - const timeout = setTimeout(() => { - if (pendingFilterRef.current !== pending) return - timeoutRef.current = null - flush() - }, FILTER_DEBOUNCE_MS) - timeoutRef.current = timeout - - return () => { - clearTimeout(timeout) - if (timeoutRef.current === timeout) timeoutRef.current = null - } - }, [rules, columns, flush]) + if (signature === lastAppliedFilterRef.current) return + lastAppliedFilterRef.current = signature + onChangeRef.current(nextFilter) + }, [rules, columns]) return (
@@ -216,7 +169,7 @@ export const TableFilter = forwardRef(funct
) -}) +} interface FilterRuleRowProps { rule: FilterRule @@ -311,11 +264,9 @@ const FilterRuleRow = memo(function FilterRuleRow({ className='min-w-[100px] flex-1' /> ) : ( - onUpdate(rule.id, 'value', e.target.value)} - placeholder='Enter a value' - className='flex-1' + onCommit={(value) => onUpdate(rule.id, 'value', value)} /> )} @@ -332,6 +283,43 @@ const FilterRuleRow = memo(function FilterRuleRow({ ) }) +interface FilterValueInputProps { + value: string + onCommit: (value: string) => void +} + +/** + * Locally buffered value field: keystrokes stay in the field until Enter or + * blur commits them — the spreadsheet-cell contract. Every click-driven exit + * from the panel (closing it, switching views, navigating away) blurs the + * field first, so finishing-by-leaving commits without any imperative + * coordination. An external reseed (column switch clearing the value, a view + * replacement remount) adopts the incoming value over the draft. + */ +function FilterValueInput({ value, onCommit }: FilterValueInputProps) { + const [draft, setDraft] = useState(value) + const [prevValue, setPrevValue] = useState(value) + if (prevValue !== value) { + setPrevValue(value) + setDraft(value) + } + + return ( + setDraft(e.target.value)} + onBlur={() => { + if (draft !== value) onCommit(draft) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && draft !== value) onCommit(draft) + }} + placeholder='Enter a value' + className='flex-1' + /> + ) +} + function createRule(columns: ColumnDefinition[]): FilterRule { const first = columns[0] return { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index a8008228ef3..d5d49b2268a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -82,7 +82,6 @@ import { type SelectionSnapshot, TableActionBar, TableFilter, - type TableFilterHandle, TableGrid, ViewsMenu, type WorkflowConfig, @@ -249,7 +248,6 @@ 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) @@ -728,7 +726,6 @@ export function Table({ const handleSelectView = useCallback( (viewId: string | null) => { preservedViewStateRef.current = null - tableFilterRef.current?.flush() setTableParams({ view: viewId ?? ALL_VIEW_PARAM }) }, [setTableParams] @@ -1436,13 +1433,9 @@ export function Table({ // a one-line query forward. const { data: executionLog } = useLogByExecutionId(workspaceId, executionId) - // 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]) + // 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), []) const filterConfig = useMemo( () => ({ mode: 'toggle' as const, @@ -1538,7 +1531,6 @@ export function Table({ /> {filterOpen && ( Date: Wed, 19 Aug 2026 12:31:42 -0700 Subject: [PATCH 07/13] fix(tables): apply filter edits from user events --- .../table-filter/table-filter.test.tsx | 12 +++ .../components/table-filter/table-filter.tsx | 90 ++++++++++--------- .../components/views-menu/views-menu.tsx | 16 ++-- .../[workspaceId]/tables/[tableId]/table.tsx | 15 ++-- 4 files changed, 81 insertions(+), 52 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 d619a6151a8..8662ea28d01 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 @@ -144,6 +144,18 @@ describe('TableFilter', () => { expect(onChange).not.toHaveBeenCalled() }) + it('does not autosave when columns refresh without a user edit', () => { + const onChange = vi.fn() + act(() => { + root.render() + }) + act(() => { + root.render() + }) + + expect(onChange).not.toHaveBeenCalled() + }) + it('merges the OR groups as soon as the conjunction is toggled back to and', () => { 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 7e0e4512d92..97028bafab3 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,6 +1,6 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { Button, ChipDropdown, ChipInput } from '@sim/emcn' import { Plus, X } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' @@ -28,7 +28,6 @@ function selectFilterOperators(column: ColumnDefinition | undefined): Set(undefined) - const onChangeRef = useRef(onChange) const [rules, setRules] = useState(() => { const fromFilter = predicateToFilterRules(filter) return fromFilter.length > 0 ? fromFilter : [createRule(columns)] }) + const rulesRef = useRef(rules) + rulesRef.current = rules // Seed the "already applied" signature from the rules the panel actually // renders, not the raw prop: a saved tree the flat builder cannot express // (deeply nested groups, wire key order) round-trips differently, and seeding @@ -59,7 +59,21 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) { // moment the panel opens. The normalized form persists only once the user // really edits a rule. lastAppliedFilterRef.current ??= JSON.stringify(toAppliedPredicate(rules, columns)) - onChangeRef.current = onChange + + const applyRules = useCallback( + (update: (current: FilterRule[]) => FilterRule[]) => { + const nextRules = update(rulesRef.current) + rulesRef.current = nextRules + setRules(nextRules) + + const nextFilter = toAppliedPredicate(nextRules, columns) + const signature = JSON.stringify(nextFilter) + if (signature === lastAppliedFilterRef.current) return + lastAppliedFilterRef.current = signature + onChange(nextFilter) + }, + [columns, onChange] + ) // `value` is the filter field key (column id); `label` is what the user sees. const columnOptions = useMemo( @@ -73,30 +87,40 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) { ) const handleAdd = useCallback(() => { - setRules((prev) => [...prev, createRule(columns)]) - }, [columns]) + applyRules((current) => [...current, createRule(columns)]) + }, [applyRules, columns]) const handleRemove = useCallback( (id: string) => { - setRules((prev) => { - const next = prev.filter((rule) => rule.id !== id) + applyRules((current) => { + const next = current.filter((rule) => rule.id !== id) return next.length > 0 ? next : [createRule(columns)] }) }, - [columns] + [applyRules, columns] ) - const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => { - setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))) - }, []) + const handleUpdate = useCallback( + (id: string, field: keyof FilterRule, value: string) => { + applyRules((current) => + current.map((rule) => (rule.id === id ? { ...rule, [field]: value } : rule)) + ) + }, + [applyRules] + ) - const handleToggleLogical = useCallback((id: string) => { - setRules((prev) => - prev.map((r) => - r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r + const handleToggleLogical = useCallback( + (id: string) => { + applyRules((current) => + current.map((rule) => + rule.id === id + ? { ...rule, logicalOperator: rule.logicalOperator === 'and' ? 'or' : 'and' } + : rule + ) ) - ) - }, []) + }, + [applyRules] + ) // Switching a rule's column across the select boundary changes what values and // operators are valid, so clear the value and coerce an unsupported operator @@ -104,40 +128,26 @@ export function TableFilter({ columns, filter, onChange }: TableFilterProps) { // apply against a select column and be rejected server-side. const handleColumnChange = useCallback( (id: string, columnId: string) => { - setRules((prev) => - prev.map((r) => { - if (r.id !== id) return r - const previous = columnById.get(r.column) + 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 { ...r, column: columnId } + 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(r.operator) ? fallback : r.operator - return { ...r, column: columnId, operator, value: '' } + const operator = isSelect && !allowed.has(rule.operator) ? fallback : rule.operator + return { ...rule, column: columnId, operator, value: '' } }) ) }, - [columnById] + [applyRules, columnById] ) - // Applies on every rules change. Rules only change on completed gestures — - // dropdown picks, row add/remove, conjunction toggles, and the value field's - // Enter/blur commit ({@link FilterValueInput} buffers keystrokes locally) — - // so nothing is ever pending and there is nothing to lose on unmount. The - // signature guard keeps no-op changes (a blank row added, an untouched - // reseed) from writing. - useEffect(() => { - const nextFilter = toAppliedPredicate(rules, columns) - const signature = JSON.stringify(nextFilter) - if (signature === lastAppliedFilterRef.current) return - lastAppliedFilterRef.current = signature - onChangeRef.current(nextFilter) - }, [rules, columns]) - return (
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} /> ))} -
+
+ {!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' + /> )}