mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
28 files changed
+872
-664
No files matched your search
@@ -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'
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
+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/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>
|
||||
|
||||
|
||||
+1
@@ -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>('')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
Reference in new issue
Block a user