mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
Merge branch 'feat/sort-via-column-header' into feat/column-order-refactored
This commit is contained in:
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 />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+17
-19
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user