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 101acc67ee8..3b4b44c370c 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 @@ -290,7 +290,12 @@ export const SortDropdown = memo(function SortDropdown({ > {active && onClear && ( <> - + { + event.preventDefault() + onClear() + }} + > Clear sort @@ -305,7 +310,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 new file mode 100644 index 00000000000..3c812909631 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.test.tsx @@ -0,0 +1,69 @@ +/** + * @vitest-environment jsdom + */ +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' + +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') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('ColumnsMenu', () => { + it('uses the app menu styling and stays open across column changes', () => { + const onChange = vi.fn() + act(() => { + root.render() + }) + act(() => { + container + .querySelector('button') + ?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + }) + + 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(() => items[0]?.click()) + expect(onChange).toHaveBeenCalledWith(['col-name']) + + 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/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 b583ae03cce..614ad42650e 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 @@ -1,8 +1,10 @@ /** * @vitest-environment jsdom */ +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' @@ -17,6 +19,22 @@ const DEFAULT_VIEW: TableViewWire = { updatedAt: new Date('2026-08-15T01:00:00.000Z'), } +const SECOND_VIEW: TableViewWire = { + ...DEFAULT_VIEW, + id: 'view-second', + name: 'Second view', + 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).toContain('All') expect(markup).not.toContain('>View<') }) + + 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) + 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(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 335bea769ef..b49f3d03bfe 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' @@ -23,17 +23,13 @@ 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. */ 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 +49,7 @@ export const ViewsMenu = memo(function ViewsMenu({ activeViewId, onSelect, onRename, + onSetDefault, onDelete, onNewView, canEdit, @@ -129,6 +126,7 @@ export const ViewsMenu = memo(function ViewsMenu({ )} onMouseEnter={openPopover} onMouseLeave={scheduleClose} + onFocusCapture={cancelScheduledClose} > Views @@ -146,8 +144,17 @@ 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 ? [ @@ -192,11 +199,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[] } @@ -206,7 +218,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 && ( + + )}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 812b5eb3209..968f2f5955d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -80,6 +80,7 @@ import { type SelectionSnapshot, TableActionBar, TableFilter, + type TableFilterHandle, TableGrid, ViewsMenu, type WorkflowConfig, @@ -240,6 +241,7 @@ export function Table({ }) const [filter, setFilter] = useState(null) const [filterOpen, setFilterOpen] = useState(false) + const tableFilterRef = useRef(null) /** Bumped whenever the filter is replaced from outside the panel, to re-seed * its rule rows. See {@link replaceFilter}. */ const [filterSeed, setFilterSeed] = useState(0) @@ -669,6 +671,7 @@ export function Table({ const handleSelectView = useCallback( (viewId: string | null) => { + tableFilterRef.current?.flush() setTableParams({ view: viewId ?? ALL_VIEW_PARAM }) }, [setTableParams] @@ -678,6 +681,15 @@ export function Table({ setViewModal({ mode: 'rename', viewId }) }, []) + const handleSetDefaultView = useCallback((viewId: string) => { + updateViewMutation.mutate( + { viewId, isDefault: true }, + { + onError: (error) => toast.error(getErrorMessage(error, 'Failed to set default view')), + } + ) + }, []) + const handleNewView = useCallback(() => { setViewModal({ mode: 'new' }) }, []) @@ -1089,10 +1101,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) @@ -1329,7 +1344,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, @@ -1404,6 +1422,7 @@ export function Table({ activeViewId={activeView?.id ?? null} onSelect={handleSelectView} onRename={handleRenameView} + onSetDefault={handleSetDefaultView} onDelete={handleDeleteView} onNewView={handleNewView} canEdit={userPermissions.canEdit} @@ -1424,11 +1443,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 c2c9c4651d8..8198e79829f 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1565,6 +1565,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