From 21bbc93afa4fbd7269e4d1ee9b78384060ad07aa Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 30 Apr 2025 14:19:21 +0800 Subject: [PATCH] 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 Co-authored-by: Joshen Lim --- apps/studio/components/grid/SupabaseGrid.tsx | 67 ++--- .../grid/components/header/Header.tsx | 103 ++------ .../header/filter/FilterPopover.tsx | 143 +---------- .../header/filter/FilterPopoverPrimitive.tsx | 136 ++++++++++ .../components/header/sort/SortPopover.tsx | 164 +----------- .../header/sort/SortPopoverPrimitive.tsx | 236 ++++++++++++++++++ .../grid/components/header/sort/index.ts | 1 - apps/studio/components/grid/hooks/README.md | 101 ++++++++ .../grid/hooks/useSaveTableEditorState.ts | 58 +++++ .../components/grid/hooks/useTableFilter.ts | 32 +++ .../components/grid/hooks/useTableSort.ts | 50 ++++ .../DeleteConfirmationDialogs.tsx | 87 ++----- .../ForeignRowSelector/ForeignRowSelector.tsx | 36 ++- .../SidePanelEditor/SidePanelEditor.utils.tsx | 1 + .../TableGridEditor/TableGridEditor.tsx | 40 ++- .../TableEditorLayout/TableEditorMenu.tsx | 4 +- apps/studio/data/sql/execute-sql-mutation.ts | 91 ++----- apps/studio/data/table-editor/keys.ts | 4 +- .../data/table-editor/table-editor-types.ts | 37 ++- .../table-row-delete-all-mutation.ts | 5 +- .../data/table-rows/table-rows-query.ts | 45 ++-- apps/studio/data/table-rows/utils.ts | 12 +- .../project/[ref]/database/tables/[id].tsx | 16 +- .../project/[ref]/database/tables/index.tsx | 29 ++- .../pages/project/[ref]/editor/[id].tsx | 28 +-- .../pages/project/[ref]/editor/index.tsx | 4 +- apps/studio/state/table-editor-table.tsx | 5 +- apps/studio/state/table-editor.tsx | 1 - 28 files changed, 872 insertions(+), 664 deletions(-) create mode 100644 apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx create mode 100644 apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx delete mode 100644 apps/studio/components/grid/components/header/sort/index.ts create mode 100644 apps/studio/components/grid/hooks/README.md create mode 100644 apps/studio/components/grid/hooks/useSaveTableEditorState.ts create mode 100644 apps/studio/components/grid/hooks/useTableFilter.ts create mode 100644 apps/studio/components/grid/hooks/useTableSort.ts diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index d24332ae8f5..d6dfe395600 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -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(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 (
-
+
{children || ( <> diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index f88b172e15f..55847f98d3e 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -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 (
{customHeader ? ( - <>{customHeader} + customHeader + ) : snap.selectedRows.size > 0 ? ( + ) : ( - <> - {snap.selectedRows.size > 0 ? ( - - ) : ( - - )} - + )}
@@ -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 (
- - + +
{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({ diff --git a/apps/studio/components/grid/components/header/filter/FilterPopover.tsx b/apps/studio/components/grid/components/header/filter/FilterPopover.tsx index ccc6b91295c..cab1b6e51b1 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopover.tsx +++ b/apps/studio/components/grid/components/header/filter/FilterPopover.tsx @@ -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 ( - - - - - - - - - ) -} - -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(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) { - if (event.key === 'Enter') onSelectApplyFilters() - } - - return ( -
-
- {filters.map((filter, index) => ( - - ))} - {filters.length == 0 && ( -
-
No filters applied to this view
-

Add a column below to filter the view

-
- )} -
- -
- - -
-
+ ) } diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx new file mode 100644 index 00000000000..8891ff8dcef --- /dev/null +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx @@ -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(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) { + if (event.key === 'Enter') onSelectApplyFilters() + } + + return ( + + + + + +
+
+ {localFilters.map((filter, index) => ( + + ))} + {localFilters.length == 0 && ( +
+
No filters applied to this view
+

+ Add a column below to filter the view +

+
+ )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/studio/components/grid/components/header/sort/SortPopover.tsx b/apps/studio/components/grid/components/header/sort/SortPopover.tsx index cbd90ecb274..f7cc7e20400 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopover.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopover.tsx @@ -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 ( - - - - - - - - - ) -} - -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(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 ( -
- {sorts.map((sort, index) => ( - - ))} - {sorts.length === 0 && ( -
-
No sorts applied to this view
-

Add a column below to sort the view

-
- )} - - -
- {columns && columns.length > 0 ? ( - - - - ) : ( -

All columns have been added

- )} -
- -
-
-
- ) + return } diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx new file mode 100644 index 00000000000..7cba3696a96 --- /dev/null +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -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(sorts) + + // Track the last props we received for comparison + const lastSortsRef = useRef(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 = ( +
+ {localSorts.map((sort, index) => ( + + ))} + {localSorts.length === 0 && ( +
+
No sorts applied to this view
+

Add a column below to sort the view

+
+ )} + + +
+ {dropdownOptions && dropdownOptions.length > 0 ? ( + + + + ) : ( +

All columns have been added

+ )} +
+ +
+
+
+ ) + + return ( + + + + + + {content} + + + ) +} diff --git a/apps/studio/components/grid/components/header/sort/index.ts b/apps/studio/components/grid/components/header/sort/index.ts deleted file mode 100644 index 8f4094016a6..00000000000 --- a/apps/studio/components/grid/components/header/sort/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as SortPopover } from './SortPopover' diff --git a/apps/studio/components/grid/hooks/README.md b/apps/studio/components/grid/hooks/README.md new file mode 100644 index 00000000000..559e08b58dc --- /dev/null +++ b/apps/studio/components/grid/hooks/README.md @@ -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 ( + <> + + + + + {/* 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 diff --git a/apps/studio/components/grid/hooks/useSaveTableEditorState.ts b/apps/studio/components/grid/hooks/useSaveTableEditorState.ts new file mode 100644 index 00000000000..9157100956c --- /dev/null +++ b/apps/studio/components/grid/hooks/useSaveTableEditorState.ts @@ -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 } +} diff --git a/apps/studio/components/grid/hooks/useTableFilter.ts b/apps/studio/components/grid/hooks/useTableFilter.ts new file mode 100644 index 00000000000..0181bbce9ca --- /dev/null +++ b/apps/studio/components/grid/hooks/useTableFilter.ts @@ -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 + } +} diff --git a/apps/studio/components/grid/hooks/useTableSort.ts b/apps/studio/components/grid/hooks/useTableSort.ts new file mode 100644 index 00000000000..9cbd1c70a7a --- /dev/null +++ b/apps/studio/components/grid/hooks/useTableSort.ts @@ -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, + } +} diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 62bb1fd0627..361f79ea9fe 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -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(), }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx index 221e6bfa353..92eab845314 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx @@ -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 = ({
- - +
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 5b9799e38ea..af57fb9decb 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -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 diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index df3d071c0cb..2ad8c0c54ca 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -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} /> +
) diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index df73b7f6ade..3c4ea8c51bb 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -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('') diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index 01df31c8bc1..b5ddceaf427 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -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 -} diff --git a/apps/studio/data/table-editor/keys.ts b/apps/studio/data/table-editor/keys.ts index 6ce556c9754..851eefd9838 100644 --- a/apps/studio/data/table-editor/keys.ts +++ b/apps/studio/data/table-editor/keys.ts @@ -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), } diff --git a/apps/studio/data/table-editor/table-editor-types.ts b/apps/studio/data/table-editor/table-editor-types.ts index 1014acac8cd..b793f43111d 100644 --- a/apps/studio/data/table-editor/table-editor-types.ts +++ b/apps/studio/data/table-editor/table-editor-types.ts @@ -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, + } +} diff --git a/apps/studio/data/table-rows/table-row-delete-all-mutation.ts b/apps/studio/data/table-rows/table-row-delete-all-mutation.ts index 0fc55394fb1..bfd96398af4 100644 --- a/apps/studio/data/table-rows/table-row-delete-all-mutation.ts +++ b/apps/studio/data/table-rows/table-row-delete-all-mutation.ts @@ -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 } diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index 56825ec17d7..1241ab9e4cf 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -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'}` + ) } } diff --git a/apps/studio/data/table-rows/utils.ts b/apps/studio/data/table-rows/utils.ts index 1cd131be63a..bb6e78a811f 100644 --- a/apps/studio/data/table-rows/utils.ts +++ b/apps/studio/data/table-rows/utils.ts @@ -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) diff --git a/apps/studio/pages/project/[ref]/database/tables/[id].tsx b/apps/studio/pages/project/[ref]/database/tables/[id].tsx index c4f3da0e276..9c834807105 100644 --- a/apps/studio/pages/project/[ref]/database/tables/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/tables/[id].tsx @@ -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 = () => { - - + {project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && ( + + + + + )} ) } diff --git a/apps/studio/pages/project/[ref]/database/tables/index.tsx b/apps/studio/pages/project/[ref]/database/tables/index.tsx index 512e29931cf..c56310f5d36 100644 --- a/apps/studio/pages/project/[ref]/database/tables/index.tsx +++ b/apps/studio/pages/project/[ref]/database/tables/index.tsx @@ -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() + const [selectedTableToEdit, setSelectedTableToEdit] = useState() return ( <> @@ -24,15 +28,15 @@ const DatabaseTables: NextPageWithLayout = () => { { - 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 = () => { - - + {projectRef !== undefined && + selectedTableToEdit !== undefined && + isTableLike(selectedTableToEdit) && ( + + + + )} + + ) } diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index c1f16f54964..da2077944bf 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -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 ( - <> - - - - ) + return } TableEditorPage.getLayout = (page) => ( diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx index 2f3bebfa86f..e1a129c3808 100644 --- a/apps/studio/pages/project/[ref]/editor/index.tsx +++ b/apps/studio/pages/project/[ref]/editor/index.tsx @@ -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]) diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index f35aaccdaf3..60c133eecea 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -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 diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 6230e27716d..be940f4135e 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -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'