Chore/table editor filter sorts logic moved to hooks (#35138)

* init

* update Popovers to use new hooks

* Update Header.tsx

* made primitive components for filter and sorts

* Delete FilterPopoverWrapper.tsx

* Delete SortPopoverWrapper.tsx

* remove

* Create README.md

* Update README.md

* fix sort popover issues

* Update SupabaseGrid.tsx

* move DeleteConfirmationDialogs into context

* fix issue with

* more stuff for alaister

* fix ts and tables pages

* First round of clean up

* Update README.md

* Smol fix

* Fix issues identified

* Smol fix

* Fix updating table name in database/tables not invalidating

* Improve SQL editor invalidation logic

* Add fix to reopen last opened table when landing on table editor

* Smol fix

---------

Co-authored-by: Alaister Young <a@alaisteryoung.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2025-04-30 14:19:21 +08:00
1 parent a5f9142fd4
commit 21bbc93afa
28 files changed
+872 -664

No files matched your search

+17 -50
View File
@@ -1,4 +1,4 @@
import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import { DataGridHandle } from 'react-data-grid'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
@@ -7,24 +7,21 @@ import { createPortal } from 'react-dom'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { RoleImpersonationState } from 'lib/role-impersonation'
import { EMPTY_ARR } from 'lib/void'
import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
filtersToUrlParams,
formatFilterURLParams,
formatSortURLParams,
saveTableEditorStateToLocalStorage,
} from './SupabaseGrid.utils'
import { Shortcuts } from './components/common/Shortcuts'
import Footer from './components/footer/Footer'
import { Grid } from './components/grid/Grid'
import Header, { HeaderProps } from './components/header/Header'
import { RowContextMenu } from './components/menu'
import { Filter, GridProps } from './types'
import { GridProps } from './types'
import { useTableFilter } from './hooks/useTableFilter'
import { useTableSort } from './hooks/useTableSort'
export const SupabaseGrid = ({
customHeader,
@@ -39,43 +36,15 @@ export const SupabaseGrid = ({
const tableId = _id ? Number(_id) : undefined
const { project } = useProjectContext()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const gridRef = useRef<DataGridHandle>(null)
const [mounted, setMounted] = useState(false)
const { filters: filter, sorts: sort, setParams } = useTableEditorFiltersSort()
const sorts = formatSortURLParams(snap.table.name, sort as string[] | undefined)
const filters = formatFilterURLParams(filter as string[])
const onApplyFilters = useCallback(
(appliedFilters: Filter[]) => {
snap.setEnforceExactCount(false)
// Reset page to 1 when filters change
snap.setPage(1)
const filters = filtersToUrlParams(appliedFilters)
setParams((prevParams) => {
return {
...prevParams,
filter: filters,
}
})
if (project?.ref) {
saveTableEditorStateToLocalStorage({
projectRef: project.ref,
tableName: snap.table.name,
schema: snap.table.schema,
filters: filters,
})
}
},
[project?.ref, snap.table.name, snap.table.schema]
)
const { filters, onApplyFilters } = useTableFilter()
const { sorts, onApplySorts } = useTableSort()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
@@ -93,15 +62,13 @@ export const SupabaseGrid = ({
{
keepPreviousData: true,
retryDelay: (retryAttempt, error: any) => {
if (error && error.message?.includes('does not exist')) {
setParams((prevParams) => {
return {
...prevParams,
...{ sort: undefined },
}
})
}
if (retryAttempt > 3) {
const doesNotExistError = error && error.message?.includes('does not exist')
const tooManyRequestsError = error.message?.includes('Too Many Requests')
const vaultError = error.message?.includes('query vault failed')
if (doesNotExistError) onApplySorts([])
if (retryAttempt > 3 || doesNotExistError || tooManyRequestsError || vaultError) {
return Infinity
}
return 5000
@@ -118,7 +85,7 @@ export const SupabaseGrid = ({
return (
<DndProvider backend={HTML5Backend} context={window}>
<div className="sb-grid h-full flex flex-col">
<Header sorts={sorts} filters={filters} customHeader={customHeader} />
<Header customHeader={customHeader} />
{children || (
<>
@@ -3,16 +3,12 @@ import saveAs from 'file-saver'
import { ArrowUp, ChevronDown, FileText, Trash } from 'lucide-react'
import Link from 'next/link'
import Papa from 'papaparse'
import { ReactNode, useCallback, useState } from 'react'
import { ReactNode, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
filtersToUrlParams,
saveTableEditorStateToLocalStorage,
sortsToUrlParams,
} from 'components/grid/SupabaseGrid.utils'
import type { Filter, Sort } from 'components/grid/types'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import GridHeaderActions from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -22,7 +18,6 @@ import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { RoleImpersonationState } from 'lib/role-impersonation'
import {
useRoleImpersonationStateSnapshot,
@@ -40,8 +35,8 @@ import {
Separator,
SonnerProgress,
} from 'ui'
import FilterPopover from './filter/FilterPopover'
import { SortPopover } from './sort'
import { FilterPopover } from './filter/FilterPopover'
import { SortPopover } from './sort/SortPopover'
// [Joshen] CSV exports require this guard as a fail-safe if the table is
// just too large for a browser to keep all the rows in memory before
// exporting. Either that or export as multiple CSV sheets with max n rows each
@@ -58,27 +53,21 @@ export const MAX_EXPORT_ROW_COUNT_MESSAGE = (
)
export type HeaderProps = {
sorts: Sort[]
filters: Filter[]
customHeader: ReactNode
}
const Header = ({ sorts, filters, customHeader }: HeaderProps) => {
const Header = ({ customHeader }: HeaderProps) => {
const snap = useTableEditorTableStateSnapshot()
return (
<div>
<div className="flex h-10 items-center justify-between bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2 overflow-x-auto ">
{customHeader ? (
<>{customHeader}</>
customHeader
) : snap.selectedRows.size > 0 ? (
<RowHeader />
) : (
<>
{snap.selectedRows.size > 0 ? (
<RowHeader sorts={sorts} filters={filters} />
) : (
<DefaultHeader />
)}
</>
<DefaultHeader />
)}
<GridHeaderActions table={snap.originalTable} />
</div>
@@ -93,6 +82,8 @@ const DefaultHeader = () => {
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const org = useSelectedOrganization()
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
const { mutate: sendEvent } = useSendEventMutation()
const onAddRow =
snap.editable && (snap.table.columns ?? []).length > 0 ? tableEditorSnap.onAddRow : undefined
@@ -101,68 +92,11 @@ const DefaultHeader = () => {
const canAddNew = onAddRow !== undefined || onAddColumn !== undefined
// [Joshen] Using this logic to block both column and row creation/update/delete
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
const { filters, sorts, setParams } = useTableEditorFiltersSort()
const onApplyFilters = useCallback(
(appliedFilters: Filter[]) => {
snap.setEnforceExactCount(false)
// Reset page to 1 when filters change
snap.setPage(1)
const newFilters = filtersToUrlParams(appliedFilters)
setParams((prevParams) => {
return {
filter: newFilters,
sort: prevParams.sort,
}
})
if (projectRef) {
saveTableEditorStateToLocalStorage({
projectRef,
tableName: snap.table.name,
schema: snap.table.schema,
filters: newFilters,
})
}
},
[projectRef, snap.table.name, snap.table.schema, setParams]
)
const onApplySorts = useCallback(
(appliedSorts: Sort[]) => {
const newSorts = sortsToUrlParams(appliedSorts)
setParams((prevParams) => {
return {
filter: prevParams.filter,
sort: newSorts,
}
})
if (projectRef) {
saveTableEditorStateToLocalStorage({
projectRef,
tableName: snap.table.name,
schema: snap.table.schema,
sorts: newSorts,
})
}
},
[projectRef, snap.table.name, snap.table.schema, setParams]
)
const { mutate: sendEvent } = useSendEventMutation()
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<FilterPopover filters={filters} onApplyFilters={onApplyFilters} />
<SortPopover sorts={sorts} onApplySorts={onApplySorts} />
<FilterPopover />
<SortPopover />
</div>
{canAddNew && (
<>
@@ -284,11 +218,7 @@ const DefaultHeader = () => {
)
}
type RowHeaderProps = {
sorts: Sort[]
filters: Filter[]
}
const RowHeader = ({ sorts, filters }: RowHeaderProps) => {
const RowHeader = () => {
const { project } = useProjectContext()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -296,6 +226,9 @@ const RowHeader = ({ sorts, filters }: RowHeaderProps) => {
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const isImpersonatingRole = roleImpersonationState.role !== undefined
const { filters } = useTableFilter()
const { sorts } = useTableSort()
const [isExporting, setIsExporting] = useState(false)
const { data } = useTableRowsQuery({
@@ -1,145 +1,22 @@
import update from 'immutability-helper'
import { isEqual } from 'lodash'
import { FilterIcon, Plus } from 'lucide-react'
import { KeyboardEvent, useCallback, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import type { Filter } from 'components/grid/types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import FilterRow from './FilterRow'
import { FilterPopoverPrimitive } from './FilterPopoverPrimitive'
export interface FilterPopoverProps {
filters: string[]
portal?: boolean
onApplyFilters: (filters: Filter[]) => void
}
const FilterPopover = ({ filters, portal = true, onApplyFilters }: FilterPopoverProps) => {
const [open, setOpen] = useState(false)
export const FilterPopover = ({ portal = true }: FilterPopoverProps) => {
const { urlFilters, onApplyFilters } = useTableFilter()
const btnText =
(filters || []).length > 0
? `Filtered by ${filters.length} rule${filters.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={(filters || []).length > 0 ? 'link' : 'text'} icon={<FilterIcon />}>
{btnText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<FilterOverlay filters={filters} onApplyFilters={onApplyFilters} />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default FilterPopover
interface FilterOverlayProps {
filters: string[]
onApplyFilters: (filter: Filter[]) => void
}
const FilterOverlay = ({ filters: filtersFromUrl, onApplyFilters }: FilterOverlayProps) => {
const snap = useTableEditorTableStateSnapshot()
const initialFilters = useMemo(
() => formatFilterURLParams((filtersFromUrl as string[]) ?? []),
[filtersFromUrl]
)
const [filters, setFilters] = useState<Filter[]>(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) =>
update(currentFilters, {
[index]: {
$set: filter,
},
})
)
}, [])
const onDeleteFilter = useCallback((index: number) => {
setFilters((currentFilters) =>
update(currentFilters, {
$splice: [[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>
<FilterPopoverPrimitive portal={portal} filters={filters} onApplyFilters={onApplyFilters} />
)
}
@@ -0,0 +1,136 @@
import { isEqual } from 'lodash'
import { Filter as FilterIcon, Plus } from 'lucide-react'
import { KeyboardEvent, useCallback, useMemo, useState } from 'react'
import type { Filter } from 'components/grid/types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
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
}
export 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_>
)
}
@@ -1,163 +1,23 @@
import update from 'immutability-helper'
import { isEqual } from 'lodash'
import { ChevronDown, List } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils'
import { DropdownControl } from 'components/grid/components/common/DropdownControl'
import type { Sort } from 'components/grid/types'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import SortRow from './SortRow'
import { SortPopoverPrimitive } from './SortPopoverPrimitive'
export interface SortPopoverProps {
sorts: string[]
portal?: boolean
onApplySorts: (sorts: Sort[]) => void
}
const SortPopover = ({ sorts, portal = true, onApplySorts }: SortPopoverProps) => {
const [open, setOpen] = useState(false)
export const SortPopover = ({ portal = true }: SortPopoverProps) => {
const { urlSorts, onApplySorts } = useTableSort()
const tableState = useTableEditorTableStateSnapshot()
const tableName = tableState?.table?.name || ''
const btnText =
(sorts || []).length > 0
? `Sorted by ${sorts.length} rule${sorts.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={(sorts || []).length > 0 ? 'link' : 'text'} icon={<List />}>
{btnText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<SortOverlay sorts={sorts} onApplySorts={onApplySorts} />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default SortPopover
export interface SortOverlayProps {
sorts: string[]
onApplySorts: (sorts: Sort[]) => void
}
const SortOverlay = ({ sorts: sortsFromUrl, onApplySorts }: SortOverlayProps) => {
const snap = useTableEditorTableStateSnapshot()
const initialSorts = useMemo(
() => formatSortURLParams(snap.table.name, sortsFromUrl ?? []),
[snap.table.name, sortsFromUrl]
)
const [sorts, setSorts] = useState<Sort[]>(initialSorts)
const columns = snap.table.columns.filter((x) => {
// exclude json/jsonb columns from sorting. Sorting by json fields in PG is only possible if you provide key from
// the JSON object.
if (x.dataType === 'json' || x.dataType === 'jsonb') {
return false
}
const found = sorts.find((y) => y.column == x.name)
return !found
})
const dropdownOptions =
columns?.map((x) => {
return { value: x.name, label: x.name }
}) || []
function onAddSort(columnName: string | number) {
setSorts([...sorts, { table: snap.table.name, 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 idx = currentSorts.findIndex((x) => x.column === column)
return update(currentSorts, {
[idx]: {
$merge: { ascending },
},
})
})
}, [])
const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => {
setSorts((currentSort) =>
update(currentSort, {
$splice: [
[dragIndex, 1],
[hoverIndex, 0, currentSort[dragIndex]],
],
})
)
}, [])
return (
<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>
)
return <SortPopoverPrimitive portal={portal} sorts={sorts} onApplySorts={onApplySorts} />
}
@@ -0,0 +1,236 @@
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.
*/
export 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_>
)
}
@@ -1 +0,0 @@
export { default as SortPopover } from './SortPopover'
+101
View File
@@ -0,0 +1,101 @@
# 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:
- 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
## 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
@@ -0,0 +1,58 @@
import { useCallback } from 'react'
import { saveTableEditorStateToLocalStorage } from 'components/grid/SupabaseGrid.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
/**
* Hook for saving state and triggering side effects.
*/
export function useSaveTableEditorState() {
const { project } = useProjectContext()
const snap = useTableEditorTableStateSnapshot()
const saveDataAndTriggerSideEffects = useCallback(
(dataToSave: { filters?: string[]; sorts?: string[] }) => {
const projectRef = project?.ref
if (!projectRef) {
return console.warn(
'[useSaveTableEditorState] ProjectRef missing, cannot save or trigger side effects.'
)
}
try {
snap.setPage(1)
snap.setEnforceExactCount(false)
const tableName = snap.table?.name
const schema = snap.table?.schema
if (tableName) {
saveTableEditorStateToLocalStorage({
projectRef,
tableName,
schema,
...dataToSave,
})
} else {
console.warn('[useSaveTableEditorState] Table name missing, skipping localStorage save.')
}
} catch (error) {
console.error('[useSaveTableEditorState] Error during interaction with snapshot:', error)
}
},
[snap, project]
)
const saveFiltersAndTriggerSideEffects = useCallback(
(urlFilters: string[]) => saveDataAndTriggerSideEffects({ filters: urlFilters }),
[saveDataAndTriggerSideEffects]
)
const saveSortsAndTriggerSideEffects = useCallback(
(urlSorts: string[]) => saveDataAndTriggerSideEffects({ sorts: urlSorts }),
[saveDataAndTriggerSideEffects]
)
return { saveFiltersAndTriggerSideEffects, saveSortsAndTriggerSideEffects }
}
@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { filtersToUrlParams, formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import type { Filter } from 'components/grid/types'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { useSaveTableEditorState } from './useSaveTableEditorState'
/**
* Hook for managing table filter URL parameters and saving.
* NO direct snapshot interaction.
*/
export function useTableFilter() {
const { filters: urlFilters, setParams } = useTableEditorFiltersSort()
const { saveFiltersAndTriggerSideEffects } = useSaveTableEditorState()
const filters = formatFilterURLParams(urlFilters)
const onApplyFilters = useCallback(
(appliedFilters: Filter[]) => {
const newUrlFilters = filtersToUrlParams(appliedFilters)
setParams((prevParams) => ({ ...prevParams, filter: newUrlFilters }))
saveFiltersAndTriggerSideEffects(newUrlFilters)
},
[setParams, saveFiltersAndTriggerSideEffects]
)
return {
filters, // Formatted Filter[] object array
urlFilters, // Raw string[] from URL
onApplyFilters, // Callback to apply changes
}
}
@@ -0,0 +1,50 @@
import { useCallback, useMemo } from 'react'
import { formatSortURLParams, sortsToUrlParams } from 'components/grid/SupabaseGrid.utils'
import type { Sort } from 'components/grid/types'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { useSaveTableEditorState } from './useSaveTableEditorState'
/**
* Hook for managing table sort URL parameters and saving.
* Uses snapshot ONLY to get table name for formatting/mapping.
* Uses useSaveTableEditorState for saving and side effects.
* Does NOT format initial sorts (needs table name externally).
* Does NOT interact with snapshot directly.
*/
export function useTableSort() {
const { sorts: urlSorts, setParams } = useTableEditorFiltersSort()
const snap = useTableEditorTableStateSnapshot()
const { saveSortsAndTriggerSideEffects } = useSaveTableEditorState()
const tableName = useMemo(() => snap.table?.name || '', [snap])
const sorts = useMemo(() => {
return formatSortURLParams(tableName, urlSorts)
}, [tableName, urlSorts])
const onApplySorts = useCallback(
(appliedSorts: Sort[]) => {
if (!tableName) {
return console.warn(
'[useTableSort] Table name missing in callback, cannot apply sort correctly.'
)
}
const sortsWithTable = appliedSorts.map((sort) => ({ ...sort, table: tableName }))
const newUrlSorts = sortsToUrlParams(sortsWithTable)
setParams((prevParams) => ({ ...prevParams, sort: newUrlSorts }))
saveSortsAndTriggerSideEffects(newUrlSorts)
},
[snap, setParams, saveSortsAndTriggerSideEffects]
)
return {
sorts,
urlSorts,
onApplySorts,
}
}
@@ -1,106 +1,50 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { toast } from 'sonner'
import {
formatFilterURLParams,
saveTableEditorStateToLocalStorageDebounced,
} from 'components/grid/SupabaseGrid.utils'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import type { SupaRow } from 'components/grid/types'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseColumnDeleteMutation } from 'data/database-columns/database-column-delete-mutation'
import { Entity } from 'data/table-editor/table-editor-types'
import { TableLike } from 'data/table-editor/table-editor-types'
import { useTableRowDeleteAllMutation } from 'data/table-rows/table-row-delete-all-mutation'
import { useTableRowDeleteMutation } from 'data/table-rows/table-row-delete-mutation'
import { useTableRowTruncateMutation } from 'data/table-rows/table-row-truncate-mutation'
import { useTableDeleteMutation } from 'data/tables/table-delete-mutation'
import { TablesData, useGetTables } from 'data/tables/tables-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { noop } from 'lib/void'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, Checkbox } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
export type DeleteConfirmationDialogsProps = {
selectedTable?: Entity | PostgresTable
onAfterDeleteTable?: (tables: TablesData) => void
selectedTable?: TableLike
onTableDeleted?: () => void
}
const DeleteConfirmationDialogs = ({
selectedTable,
onAfterDeleteTable = noop,
onTableDeleted,
}: DeleteConfirmationDialogsProps) => {
const { project } = useProjectContext()
const snap = useTableEditorStateSnapshot()
const { selectedSchema } = useQuerySchemaState()
const { filters: filter, setParams } = useTableEditorFiltersSort()
const filters = formatFilterURLParams(filter as string[])
const getTables = useGetTables({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { filters, onApplyFilters } = useTableFilter()
const removeDeletedColumnFromFiltersAndSorts = ({
ref,
tableName,
schema,
columnName,
}: {
ref: string
tableName: string
schema: string
ref?: string
tableName?: string
schema?: string
columnName: string
}) => {
setParams((prevParams) => {
const existingFilters = (prevParams?.filter ?? []) as string[]
const existingSorts = (prevParams?.sort ?? []) as string[]
const newFiltersAndSorts = {
filter: existingFilters.filter((filter: string) => {
const [column] = filter.split(':')
if (column !== columnName) return filter
}),
sort: existingSorts.filter((sort: string) => {
const [column] = sort.split(':')
if (column !== columnName) return sort
}),
}
// Overwrite local storage without the deleted column
saveTableEditorStateToLocalStorageDebounced({
projectRef: ref,
tableName,
schema,
filters: newFiltersAndSorts.filter,
sorts: newFiltersAndSorts.sort,
})
return {
...prevParams,
...newFiltersAndSorts,
}
})
onApplyFilters(filters.filter((filter) => filter.column !== columnName))
}
const { mutate: deleteColumn } = useDatabaseColumnDeleteMutation({
onSuccess: () => {
if (!(snap.confirmationDialog?.type === 'column')) return
const selectedColumnToDelete = snap.confirmationDialog.column
if (!project?.ref) return
if (!selectedTable?.name) return
removeDeletedColumnFromFiltersAndSorts({
ref: project?.ref,
tableName: selectedTable?.name,
schema: selectedColumnToDelete.schema,
columnName: selectedColumnToDelete.name,
})
removeDeletedColumnFromFiltersAndSorts({ columnName: selectedColumnToDelete.name })
toast.success(`Successfully deleted column "${selectedColumnToDelete.name}"`)
},
onError: (error) => {
@@ -114,9 +58,8 @@ const DeleteConfirmationDialogs = ({
})
const { mutate: deleteTable } = useTableDeleteMutation({
onSuccess: async () => {
const tables = await getTables(selectedSchema)
onAfterDeleteTable(tables)
toast.success(`Successfully deleted table "${selectedTable?.name}"`)
onTableDeleted?.()
},
onError: (error) => {
toast.error(`Failed to delete ${selectedTable?.name}: ${error.message}`)
@@ -231,13 +174,13 @@ const DeleteConfirmationDialogs = ({
truncateRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
table: selectedTable,
})
} else {
deleteAllRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
table: selectedTable,
filters,
roleImpersonationState: getImpersonatedRoleState(),
})
@@ -246,7 +189,7 @@ const DeleteConfirmationDialogs = ({
deleteRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
table: selectedTable,
rows: selectedRowsToDelete as SupaRow[],
roleImpersonationState: getImpersonatedRoleState(),
})
@@ -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/SortPopoverPrimitive'
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>
@@ -764,6 +764,7 @@ export const updateTable = async ({
queryClient.invalidateQueries(databaseKeys.foreignKeyConstraints(projectRef, table.schema)),
queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, table.id)),
queryClient.invalidateQueries(entityTypeKeys.list(projectRef)),
queryClient.invalidateQueries(tableKeys.list(projectRef, table.schema, true)),
])
// We need to invalidate tableRowsAndCount after tableEditor
@@ -13,16 +13,19 @@ import {
isTableLike,
isView,
} from 'data/table-editor/table-editor-types'
import { useGetTables } from 'data/tables/tables-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import { handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs'
import { createTabId, handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs'
import { Button } from 'ui'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import DeleteConfirmationDialogs from './DeleteConfirmationDialogs'
import SidePanelEditor from './SidePanelEditor/SidePanelEditor'
import TableDefinition from './TableDefinition'
@@ -40,8 +43,10 @@ export const TableGridEditor = ({
const project = useSelectedProject()
const appSnap = useAppStateSnapshot()
const { ref: projectRef, id } = useParams()
const tabs = useTabsStore(projectRef)
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const { selectedSchema } = useQuerySchemaState()
useLoadTableEditorStateFromLocalStorageIntoUrl({
projectRef,
@@ -55,6 +60,17 @@ export const TableGridEditor = ({
const isReadOnly = !canEditTables && !canEditColumns
const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined
const getTables = useGetTables({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const onClearDashboardHistory = () => {
if (projectRef && editor) {
appSnap.setDashboardHistory(projectRef, editor === 'table' ? 'editor' : editor, undefined)
}
}
const onTableCreated = useCallback(
(table: { id: number }) => {
router.push(`/project/${projectRef}/editor/${table.id}`)
@@ -62,11 +78,21 @@ export const TableGridEditor = ({
[projectRef, router]
)
const onClearDashboardHistory = () => {
if (projectRef && editor) {
appSnap.setDashboardHistory(projectRef, editor === 'table' ? 'editor' : editor, undefined)
const onTableDeleted = useCallback(async () => {
// For simplicity for now, we just open the first table within the same schema
if (isTableEditorTabsEnabled && selectedTable) {
// Close tab
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
handleTabClose({ ref: projectRef, id: tabId, router, editor, onClearDashboardHistory })
} else {
const tables = await getTables(selectedSchema)
if (tables.length > 0) {
router.push(`/project/${projectRef}/editor/${tables[0].id}`)
} else {
router.push(`/project/${projectRef}/editor`)
}
}
}
}, [getTables, isTableEditorTabsEnabled, projectRef, router, selectedSchema])
// NOTE: DO NOT PUT HOOKS AFTER THIS LINE
if (isLoadingSelectedTable || !projectRef) {
@@ -161,6 +187,10 @@ export const TableGridEditor = ({
selectedTable={isTableLike(selectedTable) ? selectedTable : undefined}
onTableCreated={onTableCreated}
/>
<DeleteConfirmationDialogs
selectedTable={isTableLike(selectedTable) ? selectedTable : undefined}
onTableDeleted={onTableDeleted}
/>
</TableEditorTableStateContextProvider>
</div>
)
@@ -46,11 +46,11 @@ import { TableMenuEmptyState } from './TableMenuEmptyState'
const TableEditorMenu = () => {
const { ref, id: _id } = useParams()
const isMobile = useBreakpoint()
const id = _id ? Number(_id) : undefined
const snap = useTableEditorStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const isMobile = useBreakpoint()
const [showModal, setShowModal] = useState(false)
const [searchText, setSearchText] = useState<string>('')
+15 -76
View File
@@ -1,16 +1,13 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { databaseExtensionsKeys } from 'data/database-extensions/keys'
import { databasePoliciesKeys } from 'data/database-policies/keys'
import { databaseRoleKeys } from 'data/database-roles/keys'
import { databaseTriggerKeys } from 'data/database-triggers/keys'
import { databaseKeys } from 'data/database/keys'
import { entityTypeKeys } from 'data/entity-types/keys'
import { enumeratedTypesKeys } from 'data/enumerated-types/keys'
import { tableKeys } from 'data/tables/keys'
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
// [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query
// So we attempt to ignore all the non-related query keys. We could probably look into grouping our query keys better
// actually to not make this too hacky here
const INVALIDATION_KEYS_IGNORE = ['branches', 'settings-v2', 'addons', 'custom-domains', 'content']
export type QueryResponseError = {
code: string
message: string
@@ -41,9 +38,16 @@ export const useExecuteSqlMutation = ({
const { contextualInvalidation, sql, projectRef } = variables
// [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate
if (contextualInvalidation && projectRef) {
const invalidationKeys = inferInvalidationKeys(projectRef, sql)
await Promise.all(invalidationKeys.map((key) => queryClient.invalidateQueries(key)))
const sqlLower = sql.toLowerCase()
const isMutationSQL =
sqlLower.includes('create ') || sqlLower.includes('alter ') || sqlLower.includes('drop ')
if (contextualInvalidation && projectRef && isMutationSQL) {
const databaseRelatedKeys = queryClient
.getQueryCache()
.findAll(['projects', projectRef])
.map((x) => x.queryKey)
.filter((x) => !INVALIDATION_KEYS_IGNORE.some((a) => x.includes(a)))
await Promise.all(databaseRelatedKeys.map((key) => queryClient.invalidateQueries(key)))
}
await onSuccess?.(data, variables, context)
},
@@ -58,68 +62,3 @@ export const useExecuteSqlMutation = ({
}
)
}
// [Joshen] Can expand this further eventually, but just covering certain easy cases for now
const inferInvalidationKeys = (ref: string, sql: string) => {
const keys = []
const sqlLower = sql.toLowerCase()
if (
sqlLower.includes('create table') ||
sqlLower.includes('alter table') ||
sqlLower.includes('drop table')
) {
keys.push(entityTypeKeys.list(ref))
keys.push(tableKeys.list(ref))
}
if (
sqlLower.includes('create schema') ||
sqlLower.includes('alter schema') ||
sqlLower.includes('drop schema')
) {
keys.push(databaseKeys.schemas(ref))
}
if (
sqlLower.includes('create function') ||
sqlLower.includes('alter function') ||
sqlLower.includes('drop function')
) {
keys.push(databaseKeys.databaseFunctions(ref))
}
if (
sqlLower.includes('create trigger') ||
sqlLower.includes('alter trigger') ||
sqlLower.includes('drop trigger')
) {
keys.push(databaseTriggerKeys.list(ref))
}
if (
sqlLower.includes('create policy') ||
sqlLower.includes('alter policy') ||
sqlLower.includes('drop policy')
) {
keys.push(databasePoliciesKeys.list(ref))
}
if (
sqlLower.includes('create type') ||
sqlLower.includes('alter type') ||
sqlLower.includes('drop type')
) {
keys.push(enumeratedTypesKeys.list(ref))
}
if (
sqlLower.includes('create role') ||
sqlLower.includes('alter role') ||
sqlLower.includes('drop role')
) {
keys.push(databaseRoleKeys.databaseRoles(ref))
}
if (sqlLower.includes('create index') || sqlLower.includes('drop index')) {
keys.push(databaseKeys.indexes(ref))
}
if (sqlLower.includes('create extension') || sqlLower.includes('drop extension')) {
keys.push(databaseExtensionsKeys.list(ref))
}
return keys
}
+2 -2
View File
@@ -1,4 +1,4 @@
export const tableEditorKeys = {
tableEditor: (projectRef: string | undefined, id: number | undefined) =>
['projects', projectRef, 'table-editor', id] as const,
tableEditor: (projectRef: string | undefined, id?: number) =>
['projects', projectRef, 'table-editor', id].filter(Boolean),
}
@@ -45,6 +45,8 @@ export interface ForeignTable {
export type Entity = Table | PartitionedTable | View | MaterializedView | ForeignTable
export type TableLike = Table | PartitionedTable
export function isTable(entity?: Entity): entity is Table {
return entity?.entity_type === ENTITY_TYPE.TABLE
}
@@ -57,7 +59,7 @@ export function isPartitionedTable(entity?: Entity): entity is PartitionedTable
* Returns true if the entity is a Table or a PartitionedTable.
* Foreign tables are not considered table-like.
*/
export function isTableLike(entity?: Entity): entity is Table | PartitionedTable {
export function isTableLike(entity?: Entity): entity is TableLike {
return isTable(entity) || isPartitionedTable(entity)
}
@@ -79,3 +81,36 @@ export function isMaterializedView(entity?: Entity): entity is MaterializedView
export function isViewLike(entity?: Entity): entity is View | MaterializedView {
return isView(entity) || isMaterializedView(entity)
}
export function postgresTableToEntity(table: PostgresTable): Entity | undefined {
if (table.columns === undefined || table.relationships === undefined) {
console.error(
'Unable to convert PostgresTable to Entity type: columns and relationships must not be undefined.'
)
return undefined
}
const tableRelationships: TableRelationship[] = table.relationships.map((rel) => ({
deletion_action: 'a',
update_action: 'a',
...rel,
}))
return {
id: table.id,
schema: table.schema,
name: table.name,
comment: table.comment,
rls_enabled: table.rls_enabled,
rls_forced: table.rls_forced,
replica_identity: table.replica_identity,
bytes: table.bytes,
size: table.size,
live_rows_estimate: table.live_rows_estimate,
dead_rows_estimate: table.dead_rows_estimate,
columns: table.columns,
relationships: tableRelationships,
primary_keys: table.primary_keys,
entity_type: ENTITY_TYPE.TABLE,
}
}
@@ -2,8 +2,9 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react
import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
import type { Filter, SupaTable } from 'components/grid/types'
import type { Filter } from 'components/grid/types'
import { executeSql } from 'data/sql/execute-sql-query'
import { Entity } from 'data/table-editor/table-editor-types'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import type { ResponseError } from 'types'
@@ -13,7 +14,7 @@ import { formatFilterValue } from './utils'
export type TableRowDeleteAllVariables = {
projectRef: string
connectionString?: string
table: SupaTable
table: Entity
filters: Filter[]
roleImpersonationState?: RoleImpersonationState
}
+25 -20
View File
@@ -1,3 +1,5 @@
import { Query } from '@supabase/pg-meta/src/query'
import { getTableRowsSql } from '@supabase/pg-meta/src/query/table-row-query'
import {
useQuery,
useQueryClient,
@@ -5,8 +7,6 @@ import {
type UseQueryOptions,
} from '@tanstack/react-query'
import { Query } from '@supabase/pg-meta/src/query'
import { getTableRowsSql } from '@supabase/pg-meta/src/query/table-row-query'
import { IS_PLATFORM } from 'common'
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types'
@@ -142,9 +142,9 @@ export const fetchAllTableRows = async ({
)
try {
const { result } = await executeWithRetry(async () =>
const { result } = await executeWithRetry(async () => {
executeSql({ projectRef, connectionString, sql: query })
)
})
rows.push(...result)
progressCallback?.(rows.length)
@@ -153,7 +153,7 @@ export const fetchAllTableRows = async ({
await sleep(THROTTLE_DELAY)
} catch (error) {
throw new Error(
`Error fetching table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
`Error fetching all table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -202,23 +202,28 @@ export async function getTableRows(
getTableRowsSql({ table: entity, filters, sorts, limit, page }),
roleImpersonationState
)
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['table-rows', table?.id],
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
},
signal
)
const rows = result.map((x: any, index: number) => {
return { idx: index, ...x }
}) as SupaRow[]
try {
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['table-rows', table?.id],
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
},
signal
)
return {
rows,
const rows = result.map((x: any, index: number) => {
return { idx: index, ...x }
}) as SupaRow[]
return { rows }
} catch (error) {
throw new Error(
`Error fetching table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
+10 -2
View File
@@ -1,4 +1,4 @@
import type { Filter, ServiceError, SupaTable } from 'components/grid/types'
import type { Filter, ServiceError } from 'components/grid/types'
import { isNumericalColumn } from 'components/grid/utils/types'
import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
@@ -6,7 +6,15 @@ import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
* temporary fix until we implement a better filter UI
* which validate input value base on the column type
*/
export function formatFilterValue(table: SupaTable, filter: Filter) {
export function formatFilterValue(
table: {
columns: {
name: string
format: string
}[]
},
filter: Filter
) {
const column = table.columns.find((x) => x.name == filter.column)
if (column && isNumericalColumn(column.format)) {
const numberValue = Number(filter.value)
@@ -11,6 +11,7 @@ import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { ChevronRight } from 'lucide-react'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import type { NextPageWithLayout } from 'types'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
@@ -50,11 +51,16 @@ const DatabaseTables: NextPageWithLayout = () => {
</ScaffoldSection>
</ScaffoldContainer>
<DeleteConfirmationDialogs selectedTable={selectedTable} />
<SidePanelEditor
includeColumns
selectedTable={isTableLike(selectedTable) ? selectedTable : undefined}
/>
{project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && (
<TableEditorTableStateContextProvider
key={`table-editor-table-${selectedTable.id}`}
projectRef={project?.ref}
table={selectedTable}
>
<DeleteConfirmationDialogs selectedTable={selectedTable} />
<SidePanelEditor includeColumns selectedTable={selectedTable} />
</TableEditorTableStateContextProvider>
)}
</>
)
}
@@ -1,6 +1,7 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { useState } from 'react'
import { PostgresTable } from '@supabase/postgres-meta'
import { useParams } from 'common'
import { TableList } from 'components/interfaces/Database'
import { SidePanelEditor } from 'components/interfaces/TableGridEditor'
import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
@@ -8,12 +9,15 @@ import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { Entity, isTableLike, postgresTableToEntity } from 'data/table-editor/table-editor-types'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import type { NextPageWithLayout } from 'types'
const DatabaseTables: NextPageWithLayout = () => {
const { ref: projectRef } = useParams()
const snap = useTableEditorStateSnapshot()
const [selectedTableToEdit, setSelectedTableToEdit] = useState<PostgresTable>()
const [selectedTableToEdit, setSelectedTableToEdit] = useState<Entity>()
return (
<>
@@ -24,15 +28,15 @@ const DatabaseTables: NextPageWithLayout = () => {
<TableList
onAddTable={snap.onAddTable}
onEditTable={(table) => {
setSelectedTableToEdit(table)
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onEditTable()
}}
onDeleteTable={(table) => {
setSelectedTableToEdit(table)
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDeleteTable()
}}
onDuplicateTable={(table) => {
setSelectedTableToEdit(table)
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDuplicateTable()
}}
/>
@@ -40,8 +44,19 @@ const DatabaseTables: NextPageWithLayout = () => {
</ScaffoldSection>
</ScaffoldContainer>
<DeleteConfirmationDialogs selectedTable={selectedTableToEdit} />
<SidePanelEditor includeColumns selectedTable={selectedTableToEdit} />
{projectRef !== undefined &&
selectedTableToEdit !== undefined &&
isTableLike(selectedTableToEdit) && (
<TableEditorTableStateContextProvider
key={`table-editor-table-${selectedTableToEdit.id}`}
projectRef={projectRef}
table={selectedTableToEdit}
>
<DeleteConfirmationDialogs selectedTable={selectedTableToEdit} />
</TableEditorTableStateContextProvider>
)}
<SidePanelEditor includeColumns selectedTable={selectedTableToEdit as PostgresTable} />
</>
)
}
@@ -1,9 +1,7 @@
import { useRouter } from 'next/router'
import { useCallback, useEffect } from 'react'
import { useEffect } from 'react'
import { useParams } from 'common'
import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
import { TableGridEditor } from 'components/interfaces/TableGridEditor/TableGridEditor'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
@@ -11,7 +9,6 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout'
import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { TablesData } from 'data/tables/tables-query'
import { addTab, createTabId, getTabsStore } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
@@ -27,19 +24,6 @@ const TableEditorPage: NextPageWithLayout = () => {
id,
})
const router = useRouter()
const onAfterDeleteTable = useCallback(
(tables: TablesData) => {
// For simplicity for now, we just open the first table within the same schema
if (tables.length > 0) {
router.push(`/project/${projectRef}/editor/${tables[0].id}`)
} else {
router.push(`/project/${projectRef}/editor`)
}
},
[router, projectRef]
)
/**
* Effect: Creates or updates tab when table is loaded
* Runs when:
@@ -70,15 +54,7 @@ const TableEditorPage: NextPageWithLayout = () => {
}
}, [selectedTable, id, projectRef, isTableEditorTabsEnabled])
return (
<>
<TableGridEditor isLoadingSelectedTable={isLoading} selectedTable={selectedTable} />
<DeleteConfirmationDialogs
selectedTable={selectedTable}
onAfterDeleteTable={onAfterDeleteTable}
/>
</>
)
return <TableGridEditor isLoadingSelectedTable={isLoading} selectedTable={selectedTable} />
}
TableEditorPage.getLayout = (page) => (
@@ -25,8 +25,8 @@ const TableEditorPage: NextPageWithLayout = () => {
}
useEffect(() => {
const lastOpenedTab = appSnap.dashboardHistory.editor
if (isTableEditorTabsEnabled) {
const lastOpenedTab = appSnap.dashboardHistory.editor
const lastTabId = tabStore.openTabs.find((id) =>
editorEntityTypes.table.includes(tabStore.tabsMap[id]?.type)
)
@@ -37,6 +37,8 @@ const TableEditorPage: NextPageWithLayout = () => {
const lastTab = tabStore.tabsMap[lastTabId]
if (lastTab) router.push(`/project/${projectRef}/editor/${lastTab.metadata?.tableId}`)
}
} else if (lastOpenedTab) {
router.push(`/project/${projectRef}/editor/${appSnap.dashboardHistory.editor}`)
}
}, [isTableEditorTabsEnabled])
+3 -2
View File
@@ -17,14 +17,15 @@ import { useTableEditorStateSnapshot } from './table-editor'
export const createTableEditorTableState = ({
projectRef,
table: originalTable,
editable,
editable = true,
onAddColumn,
onExpandJSONEditor,
onExpandTextEditor,
}: {
projectRef: string
table: Entity
editable: boolean
/** If set to true, render an additional "+" column to support adding a new column in the grid editor */
editable?: boolean
onAddColumn: () => void
onExpandJSONEditor: (column: string, row: SupaRow) => void
onExpandTextEditor: (column: string, row: SupaRow) => void
-1
View File
@@ -1,7 +1,6 @@
import type { PostgresColumn } from '@supabase/postgres-meta'
import { PropsWithChildren, createContext, useContext, useRef } from 'react'
import { proxy, useSnapshot } from 'valtio'
import { proxySet } from 'valtio/utils'
import type { SupaRow } from 'components/grid/types'
import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types'