Merge branch 'feat/sort-via-column-header' into feat/column-order-refactored

This commit is contained in:
Jonathan Summers-Muir committed 2025-04-23 19:17:43 +08:00
commit 2c741e17db
10 files changed
+635 -320

No files matched your search

@@ -7,8 +7,6 @@ import { ReactNode, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import type { Filter, Sort } from 'components/grid/types'
import GridHeaderActions from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
@@ -96,9 +94,6 @@ const DefaultHeader = () => {
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
const { mutate: sendEvent } = useSendEventMutation()
const { onApplyFilters, urlFilters } = useTableFilter()
const { onApplySorts, urlSorts } = useTableSort()
const onAddRow =
snap.editable && (snap.table.columns ?? []).length > 0 ? tableEditorSnap.onAddRow : undefined
const onAddColumn = snap.editable ? tableEditorSnap.onAddColumn : undefined
@@ -109,8 +104,8 @@ const DefaultHeader = () => {
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<FilterPopover filters={urlFilters} onApplyFilters={onApplyFilters} />
<SortPopover sorts={urlSorts} onApplySorts={onApplySorts} />
<FilterPopover />
<SortPopover />
<ColumnVisibility />
</div>
{canAddNew && (
@@ -1,140 +1,24 @@
import { isEqual } from 'lodash'
import { Filter as FilterIcon, Plus } from 'lucide-react'
import { KeyboardEvent, useCallback, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import type { Filter } from 'components/grid/types'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import FilterRow from './FilterRow'
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import FilterPopoverPrimitive from './FilterPopoverPrimitive'
export interface FilterPopoverProps {
portal?: boolean
}
const FilterPopover = ({ portal = true }: FilterPopoverProps) => {
const [open, setOpen] = useState(false)
const { urlFilters } = useTableFilter()
const { urlFilters, onApplyFilters } = useTableFilter()
const btnText =
(urlFilters || []).length > 0
? `Filtered by ${urlFilters.length} rule${urlFilters.length > 1 ? 's' : ''}`
: 'Filter'
// Convert string[] to Filter[]
const filters = useMemo(() => {
return formatFilterURLParams(urlFilters ?? [])
}, [urlFilters])
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={(urlFilters || []).length > 0 ? 'link' : 'text'} icon={<FilterIcon />}>
{btnText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<FilterOverlay />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
<FilterPopoverPrimitive portal={portal} filters={filters} onApplyFilters={onApplyFilters} />
)
}
export default FilterPopover
interface FilterOverlayProps {}
const FilterOverlay = ({}: FilterOverlayProps) => {
const snap = useTableEditorTableStateSnapshot()
const { urlFilters, onApplyFilters } = useTableFilter()
const initialFilters = useMemo(() => formatFilterURLParams(urlFilters ?? []), [urlFilters])
const [filters, setFilters] = useState<Filter[]>(initialFilters)
useMemo(() => {
setFilters(initialFilters)
}, [initialFilters])
function onAddFilter() {
const column = snap.table.columns[0]?.name
if (column) {
setFilters([
...filters,
{
column,
operator: '=',
value: '',
},
])
}
}
const onChangeFilter = useCallback((index: number, filter: Filter) => {
setFilters((currentFilters) => [
...currentFilters.slice(0, index),
filter,
...currentFilters.slice(index + 1),
])
}, [])
const onDeleteFilter = useCallback((index: number) => {
setFilters((currentFilters) => [
...currentFilters.slice(0, index),
...currentFilters.slice(index + 1),
])
}, [])
const onSelectApplyFilters = () => {
// [Joshen] Trim empty spaces in input for only UUID type columns
const formattedFilters = filters.map((f) => {
const column = snap.table.columns.find((c) => c.name === f.column)
if (column?.format === 'uuid') return { ...f, value: f.value.trim() }
else return f
})
setFilters(formattedFilters)
onApplyFilters(formattedFilters)
}
function handleEnterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') onSelectApplyFilters()
}
return (
<div className="space-y-2 py-2">
<div className="space-y-2">
{filters.map((filter, index) => (
<FilterRow
key={`filter-${filter.column}-${[index]}`}
filter={filter}
filterIdx={index}
onChange={onChangeFilter}
onDelete={onDeleteFilter}
onKeyDown={handleEnterKeyDown}
/>
))}
{filters.length == 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No filters applied to this view</h5>
<p className="text-xs text-foreground-lighter">Add a column below to filter the view</p>
</div>
)}
</div>
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
<Button icon={<Plus />} type="text" onClick={onAddFilter}>
Add filter
</Button>
<Button
disabled={isEqual(filters, initialFilters)}
type="default"
onClick={() => onSelectApplyFilters()}
>
Apply filter
</Button>
</div>
</div>
)
}
@@ -0,0 +1,138 @@
import { isEqual } from 'lodash'
import { Filter as FilterIcon, Plus } from 'lucide-react'
import { KeyboardEvent, useCallback, useMemo, useState } from 'react'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import type { Filter } from 'components/grid/types'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import FilterRow from './FilterRow'
export interface FilterPopoverPrimitiveProps {
buttonText?: string
filters: Filter[]
onApplyFilters: (filters: Filter[]) => void
portal?: boolean
}
const FilterPopoverPrimitive = ({
buttonText,
filters,
onApplyFilters,
portal = true,
}: FilterPopoverPrimitiveProps) => {
const [open, setOpen] = useState(false)
const snap = useTableEditorTableStateSnapshot()
// Internal state management
const [localFilters, setLocalFilters] = useState<Filter[]>(filters)
// Update local state when filters prop changes
useMemo(() => {
setLocalFilters(filters)
}, [filters])
const displayButtonText =
buttonText ??
(filters.length > 0
? `Filtered by ${filters.length} rule${filters.length > 1 ? 's' : ''}`
: 'Filter')
const onAddFilter = () => {
const column = snap.table.columns[0]?.name
if (column) {
setLocalFilters([
...localFilters,
{
column,
operator: '=',
value: '',
},
])
}
}
const onChangeFilter = useCallback((index: number, filter: Filter) => {
setLocalFilters((currentFilters) => [
...currentFilters.slice(0, index),
filter,
...currentFilters.slice(index + 1),
])
}, [])
const onDeleteFilter = useCallback((index: number) => {
setLocalFilters((currentFilters) => [
...currentFilters.slice(0, index),
...currentFilters.slice(index + 1),
])
}, [])
const onSelectApplyFilters = () => {
// [Joshen] Trim empty spaces in input for only UUID type columns
const formattedFilters = localFilters.map((f) => {
const column = snap.table.columns.find((c) => c.name === f.column)
if (column?.format === 'uuid') return { ...f, value: f.value.trim() }
else return f
})
setLocalFilters(formattedFilters)
onApplyFilters(formattedFilters)
}
function handleEnterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') onSelectApplyFilters()
}
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={filters.length > 0 ? 'link' : 'text'} icon={<FilterIcon />}>
{displayButtonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<div className="space-y-2 py-2">
<div className="space-y-2">
{localFilters.map((filter, index) => (
<FilterRow
key={`filter-${filter.column}-${[index]}`}
filter={filter}
filterIdx={index}
onChange={onChangeFilter}
onDelete={onDeleteFilter}
onKeyDown={handleEnterKeyDown}
/>
))}
{localFilters.length == 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No filters applied to this view</h5>
<p className="text-xs text-foreground-lighter">
Add a column below to filter the view
</p>
</div>
)}
</div>
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
<Button icon={<Plus />} type="text" onClick={onAddFilter}>
Add filter
</Button>
<Button
disabled={isEqual(localFilters, filters)}
type="default"
onClick={() => onSelectApplyFilters()}
>
Apply filter
</Button>
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default FilterPopoverPrimitive
@@ -1,182 +1,25 @@
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { isEqual } from 'lodash'
import { ChevronDown, List, Plus } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { DropdownControl } from '../../common/DropdownControl'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import type { Sort } from 'components/grid/types'
import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import SortRow from './SortRow'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import SortPopoverPrimitive from './SortPopoverPrimitive'
export interface SortPopoverProps {
portal?: boolean
}
const SortPopover = ({ portal = true }: SortPopoverProps) => {
const [open, setOpen] = useState(false)
const { urlSorts } = useTableSort()
const { urlSorts, onApplySorts } = useTableSort()
const tableState = useTableEditorTableStateSnapshot()
const tableName = tableState?.table?.name || ''
const btnText =
(urlSorts || []).length > 0
? `Sorted by ${urlSorts.length} rule${urlSorts.length > 1 ? 's' : ''}`
: 'Sort'
// Convert string[] to Sort[]
const sorts = useMemo(() => {
return tableName && urlSorts ? formatSortURLParams(tableName, urlSorts) : []
}, [tableName, urlSorts])
return (
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={(urlSorts || []).length > 0 ? 'link' : 'text'} icon={<List />}>
{btnText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<SortOverlay />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
return <SortPopoverPrimitive portal={portal} sorts={sorts} onApplySorts={onApplySorts} />
}
export default SortPopover
export interface SortOverlayProps {}
const SortOverlay = ({}: SortOverlayProps): JSX.Element => {
const snap = useTableEditorTableStateSnapshot()
const { urlSorts, onApplySorts } = useTableSort()
const initialSorts = useMemo(() => {
const tableName = snap?.table?.name || ''
return tableName ? formatSortURLParams(tableName, urlSorts ?? []) : []
}, [snap?.table?.name, urlSorts])
const [sorts, setSorts] = useState<Sort[]>(initialSorts)
useMemo(() => {
setSorts(initialSorts)
}, [initialSorts])
const columns = useMemo(() => {
if (!snap?.table?.columns) return []
return snap.table.columns.filter((x) => {
if (x.dataType === 'json' || x.dataType === 'jsonb') return false
const found = sorts.find((y) => y.column == x.name)
return !found
})
}, [snap?.table?.columns, sorts])
const dropdownOptions = useMemo(() => {
return columns?.map((x) => ({ value: x.name, label: x.name })) || []
}, [columns])
function onAddSort(columnName: string | number) {
const currentTableName = snap.table?.name
if (currentTableName) {
setSorts([
...sorts,
{ table: currentTableName, column: columnName as string, ascending: true },
])
}
}
const onDeleteSort = useCallback((column: string) => {
setSorts((currentSorts) => currentSorts.filter((sort) => sort.column !== column))
}, [])
const onToggleSort = useCallback((column: string, ascending: boolean) => {
setSorts((currentSorts) => {
const index = currentSorts.findIndex((x) => x.column === column)
if (index === -1) return currentSorts
const updatedSort = { ...currentSorts[index], ascending }
return [...currentSorts.slice(0, index), updatedSort, ...currentSorts.slice(index + 1)]
})
}, [])
const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => {
setSorts((currentSort) => {
if (
dragIndex < 0 ||
dragIndex >= currentSort.length ||
hoverIndex < 0 ||
hoverIndex >= currentSort.length
) {
return currentSort
}
const itemToMove = currentSort[dragIndex]
const remainingItems = [
...currentSort.slice(0, dragIndex),
...currentSort.slice(dragIndex + 1),
]
return [
...remainingItems.slice(0, hoverIndex),
itemToMove,
...remainingItems.slice(hoverIndex),
]
})
}, [])
return (
<DndProvider backend={HTML5Backend}>
<div className="space-y-2 py-2">
{sorts.map((sort, index) => (
<SortRow
key={sort.column}
index={index}
columnName={sort.column}
sort={sort}
onDelete={onDeleteSort}
onToggle={onToggleSort}
onDrag={onDragSort}
/>
))}
{sorts.length === 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No sorts applied to this view</h5>
<p className="text-xs text-foreground-lighter">Add a column below to sort the view</p>
</div>
)}
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
{columns && columns.length > 0 ? (
<DropdownControl
options={dropdownOptions}
onSelect={onAddSort}
side="bottom"
align="start"
>
<Button
asChild
type="text"
iconRight={<ChevronDown size="14" className="text-foreground-light" />}
className="sb-grid-dropdown__item-trigger"
data-testid="table-editor-pick-column-to-sort-button"
>
<span>Pick {sorts.length > 1 ? 'another' : 'a'} column to sort by</span>
</Button>
</DropdownControl>
) : (
<p className="text-sm text-foreground-light">All columns have been added</p>
)}
<div className="flex items-center">
<Button
disabled={isEqual(sorts, initialSorts)}
type="default"
onClick={() => onApplySorts(sorts)}
>
Apply sorting
</Button>
</div>
</div>
</div>
</DndProvider>
)
}
@@ -0,0 +1,238 @@
import { isEqual } from 'lodash'
import { ChevronDown, List } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Sort } from 'components/grid/types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import { DropdownControl } from '../../common/DropdownControl'
import SortRow from './SortRow'
export interface SortPopoverPrimitiveProps {
buttonText?: string
sorts: Sort[]
onApplySorts: (sorts: Sort[]) => void
portal?: boolean
}
/**
* SortPopoverPrimitive - A component for sorting table columns
*
* This component maintains a draft state of sorts while editing, then applies
* them to the parent component when "Apply" is clicked.
*
* To avoid issues with drag-and-drop reconciliation, we use a special sync mechanism
* that properly detects external vs. internal updates.
*/
const SortPopoverPrimitive = ({
buttonText,
sorts,
onApplySorts,
portal = true,
}: SortPopoverPrimitiveProps) => {
const [open, setOpen] = useState(false)
const snap = useTableEditorTableStateSnapshot()
// Local state for draft sorts
const [localSorts, setLocalSorts] = useState<Sort[]>(sorts)
// Track the last props we received for comparison
const lastSortsRef = useRef<Sort[]>(sorts)
// Track if we're in the middle of applying our own changes
const isApplyingRef = useRef(false)
// Sync with props when they change, but in a smarter way
useEffect(() => {
// If we're in the middle of applying changes, don't sync from props
if (isApplyingRef.current) {
isApplyingRef.current = false
return
}
// If the props changed unexpectedly (not due to our own actions)
// then we should update our local state
if (!isEqual(sorts, lastSortsRef.current)) {
setLocalSorts(sorts)
lastSortsRef.current = sorts
}
}, [sorts])
// Fix: Use localSorts for button text, not sorts
const displayButtonText =
buttonText ??
(localSorts.length > 0
? `Sorted by ${localSorts.length} rule${localSorts.length > 1 ? 's' : ''}`
: 'Sort')
// Filter available columns to exclude columns already in sorts
const columns = useMemo(() => {
if (!snap?.table?.columns) return []
return snap.table.columns.filter((x) => {
if (x.dataType === 'json' || x.dataType === 'jsonb') return false
const found = localSorts.find((y) => y.column == x.name)
return !found
})
}, [snap?.table?.columns, localSorts])
// Format the columns for the dropdown
const dropdownOptions = useMemo(() => {
return columns?.map((x) => ({ value: x.name, label: x.name })) || []
}, [columns])
// Add a new sort
const onAddSort = (columnName: string | number) => {
const currentTableName = snap.table?.name
if (currentTableName) {
setLocalSorts([
...localSorts,
{ table: currentTableName, column: columnName as string, ascending: true },
])
}
}
// Remove a sort by column name
const onDeleteSort = useCallback((column: string) => {
setLocalSorts((currentSorts) => currentSorts.filter((sort) => sort.column !== column))
}, [])
// Toggle ascending/descending for a column
const onToggleSort = useCallback((column: string, ascending: boolean) => {
setLocalSorts((currentSorts) => {
const index = currentSorts.findIndex((x) => x.column === column)
if (index === -1) return currentSorts
const updatedSort = { ...currentSorts[index], ascending }
return [...currentSorts.slice(0, index), updatedSort, ...currentSorts.slice(index + 1)]
})
}, [])
// Handle drag-and-drop reordering
const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => {
setLocalSorts((currentSort) => {
if (
dragIndex < 0 ||
dragIndex >= currentSort.length ||
hoverIndex < 0 ||
hoverIndex >= currentSort.length
) {
return currentSort
}
const itemToMove = currentSort[dragIndex]
const remainingItems = [
...currentSort.slice(0, dragIndex),
...currentSort.slice(dragIndex + 1),
]
return [
...remainingItems.slice(0, hoverIndex),
itemToMove,
...remainingItems.slice(hoverIndex),
]
})
}, [])
// Fix: Compare for meaningful changes (only column order and ascending)
const hasChanges = useMemo(() => {
if (localSorts.length !== sorts.length) return true
// Compare each sort by relevant properties
return localSorts.some((localSort, index) => {
const propSort = sorts[index]
return (
!propSort ||
localSort.column !== propSort.column ||
localSort.ascending !== propSort.ascending
)
})
}, [localSorts, sorts])
// Apply the sorts to the parent component
const onSelectApplySorts = () => {
// Mark that we're applying our changes to prevent re-syncing
isApplyingRef.current = true
// Update our last sorts ref to the current local state
lastSortsRef.current = [...localSorts]
// Create deep copies to avoid reference issues
const sortsCopy = localSorts.map((sort) => ({ ...sort }))
// Apply the sorts
onApplySorts(sortsCopy)
}
// Generate stable keys for SortRow components to avoid reconciliation issues
const getSortRowKey = (sort: Sort, index: number) => {
return `sort-${sort.table}-${sort.column}-${index}`
}
const content = (
<div className="space-y-2 py-2">
{localSorts.map((sort, index) => (
<SortRow
key={getSortRowKey(sort, index)}
index={index}
columnName={sort.column}
sort={sort}
onDelete={onDeleteSort}
onToggle={onToggleSort}
onDrag={onDragSort}
/>
))}
{localSorts.length === 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No sorts applied to this view</h5>
<p className="text-xs text-foreground-lighter">Add a column below to sort the view</p>
</div>
)}
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
{dropdownOptions && dropdownOptions.length > 0 ? (
<DropdownControl
options={dropdownOptions}
onSelect={onAddSort}
side="bottom"
align="start"
>
<Button
asChild
type="text"
iconRight={<ChevronDown size="14" className="text-foreground-light" />}
className="sb-grid-dropdown__item-trigger"
data-testid="table-editor-pick-column-to-sort-button"
>
<span>Pick {localSorts.length > 1 ? 'another' : 'a'} column to sort by</span>
</Button>
</DropdownControl>
) : (
<p className="text-sm text-foreground-light">All columns have been added</p>
)}
<div className="flex items-center">
<Button disabled={!hasChanges} type="default" onClick={onSelectApplySorts}>
Apply sorting
</Button>
</div>
</div>
</div>
)
return (
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={localSorts.length > 0 ? 'link' : 'text'} icon={<List />}>
{displayButtonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
{content}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default SortPopoverPrimitive
@@ -1 +1,2 @@
export { default as SortPopover } from './SortPopover'
export { default as SortPopoverPrimitive } from './SortPopoverPrimitive'
@@ -1,10 +1,13 @@
import { ChevronDown, Edit, Lock, Trash, Unlock } from 'lucide-react'
import type { Sort } from 'components/grid/types'
import { ArrowDown, ArrowUp, ChevronDown, Edit, Lock, Trash, Unlock } from 'lucide-react'
import type { CalculatedColumn } from 'react-data-grid'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -23,8 +26,10 @@ interface ColumnMenuProps {
const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => {
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const { sorts, addOrUpdateSort, removeSort } = useTableSort()
const columnKey = column.key
const columnName = column.name as string
function onFreezeColumn() {
snap.freezeColumn(columnKey)
@@ -48,7 +53,22 @@ const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => {
}
}
function onSortColumn(ascending: boolean) {
if (!columnKey) return
const currentSort = sorts.find((s) => s.column === columnKey)
if (currentSort && currentSort.ascending === ascending) {
// Clicked the currently active sort: Remove it
removeSort(columnKey)
} else {
// Clicked the inactive sort or column wasn't sorted: Add or update it
addOrUpdateSort(columnKey, ascending)
}
}
function renderMenu() {
const currentSort: Sort | undefined = sorts.find((s) => s.column === columnKey)
return (
<>
{snap.editable && (
@@ -80,6 +100,29 @@ const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => {
</>
)}
</DropdownMenuItem>
<Separator />
<DropdownMenuItem
className={cn(
'space-x-2',
currentSort?.ascending && 'bg-surface-200 dark:bg-surface-400 text-foreground'
)}
onClick={() => onSortColumn(true)}
>
<ArrowUp size={14} strokeWidth={currentSort?.ascending ? 3 : 1} />
<p>Sort Ascending</p>
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
'space-x-2',
currentSort &&
!currentSort.ascending &&
'bg-surface-200 dark:bg-surface-400 text-foreground'
)}
onClick={() => onSortColumn(false)}
>
<ArrowDown size={14} strokeWidth={currentSort && !currentSort.ascending ? 3 : 1} />
<p>Sort Descending</p>
</DropdownMenuItem>
{snap.editable && (
<>
<Separator />
+126
View File
@@ -0,0 +1,126 @@
# Table Filtering and Sorting Developer notes
## Overview
The table filtering and sorting system uses a URL-based state persistence pattern combined with custom hooks that abstract the implementation details from consuming components. This architecture provides several benefits:
- **Persistent state**: Filters and sorts are stored in URL parameters, enabling bookmarking and sharing
- **Separation of concerns**: Logic is separated from UI components
- **Draft-then-apply pattern**: UI components maintain draft state until explicitly applied
## Core Hooks
### `useTableFilter`
```typescript
// Returns: { filters, urlFilters, onApplyFilters }
```
The `useTableFilter` hook manages filter state with these responsibilities:
- Retrieves raw filter parameters from URL
- Formats filter parameters into usable Filter[] objects
- Provides a callback to apply new filters
- Persists changes to URL parameters
- Triggers side effects through `saveFiltersAndTriggerSideEffects`
Key design aspects:
- No direct snapshot interaction, keeping it focused solely on filter management
- Uses URL parameters as the source of truth
- Forwards filter changes to URL and triggers application-specific side effects
### `useTableSort`
```typescript
// Returns: { sorts, urlSorts, onApplySorts }
```
The `useTableSort` hook manages sort state with these responsibilities:
- Retrieves raw filter parameters from URL
- Formats sort parameters into usable Sort[] objects (needs table name)
- Provides a callback to apply new sorts
- Persists changes to URL parameters
- Triggers side effects through `saveSortsAndTriggerSideEffects`
Key design aspects:
- Uses safe snapshot ONLY to get table name for formatting
- Handles applying table name to sort objects
- Maintains URL parameters as source of truth
- Forwards sort changes to URL and triggers application-specific side effects
### `useSafeTableEditorSnapshot`
```typescript
// Returns: Partial<TableEditorTableState>
```
This hook exists is to satisfy React's rules of hooks:
React hooks CANNOT be called conditionally. Since `useTableSort` is a hook that needs table state, we cannot write:
```typescript
// THIS VIOLATES REACT'S RULES - NOT ALLOWED
function useTableSort() {
const { sorts: urlSorts } = useTableEditorFiltersSort()
// Conditional hook usage is forbidden in React!
const snap = hasTableContext ? useTableEditorTableStateSnapshot() : null
// ...rest of hook
}
```
Instead, we created `useSafeTableEditorSnapshot` which unconditionally calls the original snapshot hook and handles error cases. This allows `useTableSort` to always call the hook safely regardless of context.
## Component Implementation
### FilterPopoverPrimitive and SortPopoverPrimitive
These components follow a "draft and apply" pattern:
1. **Local state management**: Both components maintain a local state copy of filters/sorts
2. **Edit operations**: Changes like adding, modifying, or deleting are made to the local state
3. **Apply operations**: Only when the user clicks "Apply" are the changes committed via the callback
4. **Synchronization**: Local state is synchronized with props when external changes occur
## Data Flow
1. URL parameters store the raw filter/sort state
2. Hooks read and format these parameters into usable objects
3. UI components receive formatted objects and callbacks
4. Components maintain draft state for editing
5. When "Apply" is clicked, callbacks update URL parameters
6. Side effects are triggered via dedicated save hooks
## Component Usage
Components using these hooks should follow this pattern:
```tsx
function TableComponent() {
// Get filter data and callbacks
const { filters, onApplyFilters } = useTableFilter()
// Get sort data and callbacks
const { sorts, onApplySorts } = useTableSort()
return (
<>
<FilterPopoverPrimitive filters={filters} onApplyFilters={onApplyFilters} />
<SortPopoverPrimitive sorts={sorts} onApplySorts={onApplySorts} />
{/* Table rendering with filters and sorts applied */}
</>
)
}
```
## Implementation Notes
- Filter and sort parameters are stored in URL using specific formats
- Conversion utilities (`formatFilterURLParams`, `formatSortURLParams`, etc.) handle translation between URL strings and typed objects
- Side effect hooks manage database persistence and related operations
@@ -44,9 +44,58 @@ export function useTableSort() {
[snap, setParams, saveSortsAndTriggerSideEffects]
)
/**
* Adds a new sort for a column or updates the direction of an existing one.
* New sorts are added to the beginning of the array (highest precedence).
* If the column already exists, its `ascending` direction is updated.
* Calls `onApplySorts` to update URL parameters and trigger side effects.
*
* @param columnKey The key/name of the column to sort.
* @param ascending The sort direction (true for ascending, false for descending).
*/
const addOrUpdateSort = useCallback(
(columnKey: string, ascending: boolean) => {
if (!tableName || !columnKey) return
// Use the derived 'sorts' state from the hook
const existingSortIndex = sorts.findIndex((s) => s.column === columnKey)
let newSorts = [...sorts] // Create a mutable copy
if (existingSortIndex !== -1) {
// Column already exists in sorts: Update the existing sort (toggle handled by removeSort)
newSorts[existingSortIndex] = { ...newSorts[existingSortIndex], ascending: ascending }
} else {
// Column doesn't exist in sorts: Add it to the beginning
newSorts.unshift({ table: tableName, column: columnKey, ascending: ascending })
}
onApplySorts(newSorts)
},
[tableName, sorts, onApplySorts] // Depend on derived sorts and callback
)
/**
* Removes a sort criterion for a specific column.
* Calls `onApplySorts` with the filtered array to update URL parameters and trigger side effects.
*
* @param columnKey The key/name of the column to remove from sorting.
*/
const removeSort = useCallback(
(columnKey: string) => {
if (!tableName || !columnKey) return
// Use the derived 'sorts' state from the hook
const newSorts = sorts.filter((s) => s.column !== columnKey)
onApplySorts(newSorts)
},
[tableName, sorts, onApplySorts] // Depend on derived sorts and callback
)
return {
sorts,
urlSorts,
onApplySorts,
addOrUpdateSort,
removeSort,
}
}
@@ -4,15 +4,9 @@ import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { useParams } from 'common'
import {
filtersToUrlParams,
formatFilterURLParams,
formatSortURLParams,
sortsToUrlParams,
} from 'components/grid/SupabaseGrid.utils'
import RefreshButton from 'components/grid/components/header/RefreshButton'
import FilterPopover from 'components/grid/components/header/filter/FilterPopover'
import { SortPopover } from 'components/grid/components/header/sort'
import FilterPopoverPrimitive from 'components/grid/components/header/filter/FilterPopoverPrimitive'
import { SortPopoverPrimitive } from 'components/grid/components/header/sort'
import type { Filter } from 'components/grid/types'
import { Sort } from 'components/grid/types'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -65,9 +59,9 @@ const ForeignRowSelector = ({
id: tableId,
})
const [{ sort: sorts, filter: filters }, setParams] = useState<{
filter: string[]
sort: string[]
const [{ sort: sorts, filter: filters }, setFiltersAndSorts] = useState<{
filter: Filter[]
sort: Sort[]
}>({ filter: [], sort: [] })
const onApplyFilters = (appliedFilters: Filter[]) => {
@@ -76,19 +70,19 @@ const ForeignRowSelector = ({
setPage(1)
}
setParams((prevParams) => {
setFiltersAndSorts((prevParams) => {
return {
...prevParams,
filter: filtersToUrlParams(appliedFilters),
filter: appliedFilters,
}
})
}
const onApplySorts = (appliedSorts: Sort[]) => {
setParams((prevParams) => {
setFiltersAndSorts((prevParams) => {
return {
...prevParams,
sort: sortsToUrlParams(appliedSorts),
sort: appliedSorts,
}
})
}
@@ -103,8 +97,8 @@ const ForeignRowSelector = ({
projectRef: project?.ref,
connectionString: project?.connectionString,
tableId: table?.id,
sorts: formatSortURLParams(table?.name || '', sorts),
filters: formatFilterURLParams(filters),
sorts,
filters,
page,
limit: rowsPerPage,
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
@@ -160,13 +154,17 @@ const ForeignRowSelector = ({
<div className="flex items-center justify-between my-2 mx-3">
<div className="flex items-center">
<RefreshButton tableId={table?.id} isRefetching={isRefetching} />
<FilterPopover
<FilterPopoverPrimitive
portal={false}
filters={filters}
onApplyFilters={onApplyFilters}
/>
<DndProvider backend={HTML5Backend} context={window}>
<SortPopover portal={false} sorts={sorts} onApplySorts={onApplySorts} />
<SortPopoverPrimitive
portal={false}
sorts={sorts}
onApplySorts={onApplySorts}
/>
</DndProvider>
</div>