From 8a268d73433a365d29997c834deb657e8e2a0680 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Sat, 19 Apr 2025 19:02:35 +0800 Subject: [PATCH 01/10] init --- .../grid/components/menu/ColumnMenu.tsx | 45 ++++++++++++++++- .../components/grid/hooks/useTableSort.ts | 49 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/grid/components/menu/ColumnMenu.tsx b/apps/studio/components/grid/components/menu/ColumnMenu.tsx index 5ad77c71d28..7cf199e619e 100644 --- a/apps/studio/components/grid/components/menu/ColumnMenu.tsx +++ b/apps/studio/components/grid/components/menu/ColumnMenu.tsx @@ -1,10 +1,13 @@ -import { ChevronDown, Edit, Lock, Trash, Unlock } from 'lucide-react' +import type { Sort } from 'components/grid/types' +import { ArrowDown, ArrowUp, ChevronDown, Edit, Lock, Trash, Unlock } from 'lucide-react' import type { CalculatedColumn } from 'react-data-grid' +import { useTableSort } from 'components/grid/hooks/useTableSort' import { useTableEditorStateSnapshot } from 'state/table-editor' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -23,8 +26,10 @@ interface ColumnMenuProps { const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => { const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() + const { sorts, addOrUpdateSort, removeSort } = useTableSort() const columnKey = column.key + const columnName = column.name as string function onFreezeColumn() { snap.freezeColumn(columnKey) @@ -48,7 +53,22 @@ const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => { } } + function onSortColumn(ascending: boolean) { + if (!columnKey) return + const currentSort = sorts.find((s) => s.column === columnKey) + + if (currentSort && currentSort.ascending === ascending) { + // Clicked the currently active sort: Remove it + removeSort(columnKey) + } else { + // Clicked the inactive sort or column wasn't sorted: Add or update it + addOrUpdateSort(columnKey, ascending) + } + } + function renderMenu() { + const currentSort: Sort | undefined = sorts.find((s) => s.column === columnKey) + return ( <> {snap.editable && ( @@ -80,6 +100,29 @@ const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => { )} + + onSortColumn(true)} + > + +

Sort Ascending

+
+ onSortColumn(false)} + > + +

Sort Descending

+
{snap.editable && ( <> diff --git a/apps/studio/components/grid/hooks/useTableSort.ts b/apps/studio/components/grid/hooks/useTableSort.ts index b40352a25b4..9ba34d77d62 100644 --- a/apps/studio/components/grid/hooks/useTableSort.ts +++ b/apps/studio/components/grid/hooks/useTableSort.ts @@ -44,9 +44,58 @@ export function useTableSort() { [snap, setParams, saveSortsAndTriggerSideEffects] ) + /** + * Adds a new sort for a column or updates the direction of an existing one. + * New sorts are added to the beginning of the array (highest precedence). + * If the column already exists, its `ascending` direction is updated. + * Calls `onApplySorts` to update URL parameters and trigger side effects. + * + * @param columnKey The key/name of the column to sort. + * @param ascending The sort direction (true for ascending, false for descending). + */ + const addOrUpdateSort = useCallback( + (columnKey: string, ascending: boolean) => { + if (!tableName || !columnKey) return + + // Use the derived 'sorts' state from the hook + const existingSortIndex = sorts.findIndex((s) => s.column === columnKey) + let newSorts = [...sorts] // Create a mutable copy + + if (existingSortIndex !== -1) { + // Column already exists in sorts: Update the existing sort (toggle handled by removeSort) + newSorts[existingSortIndex] = { ...newSorts[existingSortIndex], ascending: ascending } + } else { + // Column doesn't exist in sorts: Add it to the beginning + newSorts.unshift({ table: tableName, column: columnKey, ascending: ascending }) + } + + onApplySorts(newSorts) + }, + [tableName, sorts, onApplySorts] // Depend on derived sorts and callback + ) + + /** + * Removes a sort criterion for a specific column. + * Calls `onApplySorts` with the filtered array to update URL parameters and trigger side effects. + * + * @param columnKey The key/name of the column to remove from sorting. + */ + const removeSort = useCallback( + (columnKey: string) => { + if (!tableName || !columnKey) return + + // Use the derived 'sorts' state from the hook + const newSorts = sorts.filter((s) => s.column !== columnKey) + onApplySorts(newSorts) + }, + [tableName, sorts, onApplySorts] // Depend on derived sorts and callback + ) + return { sorts, urlSorts, onApplySorts, + addOrUpdateSort, + removeSort, } } From 0e6f12d7e35e8018424de815888b4063e36042ec Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Sat, 19 Apr 2025 19:10:09 +0800 Subject: [PATCH 02/10] Update SortPopover.tsx --- .../components/header/sort/SortPopover.tsx | 94 +++++++++---------- 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/apps/studio/components/grid/components/header/sort/SortPopover.tsx b/apps/studio/components/grid/components/header/sort/SortPopover.tsx index 577c1c1d459..11e837a642f 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopover.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopover.tsx @@ -124,59 +124,57 @@ const SortOverlay = ({}: SortOverlayProps): JSX.Element => { }, []) return ( - -
- {sorts.map((sort, index) => ( - - ))} - {sorts.length === 0 && ( -
-
No sorts applied to this view
-

Add a column below to sort the view

-
- )} +
+ {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

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

All columns have been added

+ )} +
+
- +
) } From 23698bd722ed2658c352196a9bd75ed1b60392fe Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 17:22:09 +0800 Subject: [PATCH 03/10] Update Header.tsx --- apps/studio/components/grid/components/header/Header.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index b9642647b1f..50035cadc65 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -7,8 +7,6 @@ import { ReactNode, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useTableFilter } from 'components/grid/hooks/useTableFilter' -import { useTableSort } from 'components/grid/hooks/useTableSort' import type { Filter, Sort } from 'components/grid/types' import GridHeaderActions from 'components/interfaces/TableGridEditor/GridHeaderActions' import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils' @@ -95,9 +93,6 @@ const DefaultHeader = () => { const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') const { mutate: sendEvent } = useSendEventMutation() - const { onApplyFilters, urlFilters } = useTableFilter() - const { onApplySorts, urlSorts } = useTableSort() - const onAddRow = snap.editable && (snap.table.columns ?? []).length > 0 ? tableEditorSnap.onAddRow : undefined const onAddColumn = snap.editable ? tableEditorSnap.onAddColumn : undefined @@ -108,8 +103,8 @@ const DefaultHeader = () => { return (
- - + +
{canAddNew && ( <> From 106bc458e9931ec42253627caac320bf37a344de Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 17:47:15 +0800 Subject: [PATCH 04/10] made primitive components for filter and sorts --- .../header/filter/FilterPopover.tsx | 134 +------------ .../header/filter/FilterPopoverContent.tsx | 80 ++++++++ .../header/filter/FilterPopoverPrimitive.tsx | 138 ++++++++++++++ .../components/header/sort/SortPopover.tsx | 178 +----------------- .../header/sort/SortPopoverContent.tsx | 112 +++++++++++ .../header/sort/SortPopoverPrimitive.tsx | 177 +++++++++++++++++ .../grid/components/header/sort/index.ts | 1 + .../FilterPopoverWrapper.tsx | 17 ++ .../ForeignRowSelector/ForeignRowSelector.tsx | 36 ++-- .../ForeignRowSelector/SortPopoverWrapper.tsx | 17 ++ 10 files changed, 578 insertions(+), 312 deletions(-) create mode 100644 apps/studio/components/grid/components/header/filter/FilterPopoverContent.tsx create mode 100644 apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx create mode 100644 apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx create mode 100644 apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx diff --git a/apps/studio/components/grid/components/header/filter/FilterPopover.tsx b/apps/studio/components/grid/components/header/filter/FilterPopover.tsx index 4a6bd976979..26761361f7a 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopover.tsx +++ b/apps/studio/components/grid/components/header/filter/FilterPopover.tsx @@ -1,140 +1,24 @@ -import { isEqual } from 'lodash' -import { Filter as FilterIcon, Plus } from 'lucide-react' -import { KeyboardEvent, useCallback, useMemo, useState } from 'react' +import { useMemo } from 'react' -import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import type { Filter } from 'components/grid/types' import { useTableFilter } from 'components/grid/hooks/useTableFilter' -import { - Button, - PopoverContent_Shadcn_, - PopoverSeparator_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, -} from 'ui' -import FilterRow from './FilterRow' +import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils' +import FilterPopoverPrimitive from './FilterPopoverPrimitive' export interface FilterPopoverProps { portal?: boolean } const FilterPopover = ({ portal = true }: FilterPopoverProps) => { - const [open, setOpen] = useState(false) - const { urlFilters } = useTableFilter() + const { urlFilters, onApplyFilters } = useTableFilter() - const btnText = - (urlFilters || []).length > 0 - ? `Filtered by ${urlFilters.length} rule${urlFilters.length > 1 ? 's' : ''}` - : 'Filter' + // Convert string[] to Filter[] + const filters = useMemo(() => { + return formatFilterURLParams(urlFilters ?? []) + }, [urlFilters]) return ( - - - - - - - - + ) } export default FilterPopover - -interface FilterOverlayProps {} - -const FilterOverlay = ({}: FilterOverlayProps) => { - const snap = useTableEditorTableStateSnapshot() - const { urlFilters, onApplyFilters } = useTableFilter() - - const initialFilters = useMemo(() => formatFilterURLParams(urlFilters ?? []), [urlFilters]) - const [filters, setFilters] = useState(initialFilters) - - useMemo(() => { - setFilters(initialFilters) - }, [initialFilters]) - - function onAddFilter() { - const column = snap.table.columns[0]?.name - - if (column) { - setFilters([ - ...filters, - { - column, - operator: '=', - value: '', - }, - ]) - } - } - - const onChangeFilter = useCallback((index: number, filter: Filter) => { - setFilters((currentFilters) => [ - ...currentFilters.slice(0, index), - filter, - ...currentFilters.slice(index + 1), - ]) - }, []) - - const onDeleteFilter = useCallback((index: number) => { - setFilters((currentFilters) => [ - ...currentFilters.slice(0, index), - ...currentFilters.slice(index + 1), - ]) - }, []) - - const onSelectApplyFilters = () => { - // [Joshen] Trim empty spaces in input for only UUID type columns - const formattedFilters = filters.map((f) => { - const column = snap.table.columns.find((c) => c.name === f.column) - if (column?.format === 'uuid') return { ...f, value: f.value.trim() } - else return f - }) - setFilters(formattedFilters) - onApplyFilters(formattedFilters) - } - - function handleEnterKeyDown(event: KeyboardEvent) { - 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/FilterPopoverContent.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverContent.tsx new file mode 100644 index 00000000000..d524b946c59 --- /dev/null +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverContent.tsx @@ -0,0 +1,80 @@ +import { isEqual } from 'lodash' +import { Plus } from 'lucide-react' +import { KeyboardEvent, useCallback, useMemo } from 'react' + +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import type { Filter } from 'components/grid/types' +import { Button, PopoverSeparator_Shadcn_ } from 'ui' +import FilterRow from './FilterRow' + +export interface FilterPopoverContentProps { + filters: Filter[] + initialFilters: Filter[] + onChangeFilter: (index: number, filter: Filter) => void + onDeleteFilter: (index: number) => void + onAddFilter: () => void + onApplyFilters: (filters: Filter[]) => void +} + +const FilterPopoverContent = ({ + filters, + initialFilters, + onChangeFilter, + onDeleteFilter, + onAddFilter, + onApplyFilters, +}: FilterPopoverContentProps) => { + const snap = useTableEditorTableStateSnapshot() + + 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 + }) + 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

+
+ )} +
+ +
+ + +
+
+ ) +} + +export default FilterPopoverContent 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..3da4bcd225f --- /dev/null +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx @@ -0,0 +1,138 @@ +import { isEqual } from 'lodash' +import { Filter as FilterIcon, Plus } from 'lucide-react' +import { KeyboardEvent, useCallback, useMemo, useState } from 'react' + +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import type { Filter } from 'components/grid/types' +import { + Button, + PopoverContent_Shadcn_, + PopoverSeparator_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' +import FilterRow from './FilterRow' + +export interface FilterPopoverPrimitiveProps { + buttonText?: string + filters: Filter[] + onApplyFilters: (filters: Filter[]) => void + portal?: boolean +} + +const FilterPopoverPrimitive = ({ + buttonText, + filters, + onApplyFilters, + portal = true, +}: FilterPopoverPrimitiveProps) => { + const [open, setOpen] = useState(false) + const snap = useTableEditorTableStateSnapshot() + + // Internal state management + const [localFilters, setLocalFilters] = useState(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 +

+
+ )} +
+ +
+ + +
+
+
+
+ ) +} + +export default FilterPopoverPrimitive diff --git a/apps/studio/components/grid/components/header/sort/SortPopover.tsx b/apps/studio/components/grid/components/header/sort/SortPopover.tsx index 577c1c1d459..1c5505dcc0e 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopover.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopover.tsx @@ -1,182 +1,24 @@ -import { DndProvider } from 'react-dnd' -import { HTML5Backend } from 'react-dnd-html5-backend' -import { isEqual } from 'lodash' -import { ChevronDown, List, Plus } from 'lucide-react' -import { useCallback, useMemo, useState } from 'react' +import { useMemo } from 'react' -import { DropdownControl } from '../../common/DropdownControl' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import type { Sort } from 'components/grid/types' import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils' import { useTableSort } from 'components/grid/hooks/useTableSort' -import { - Button, - PopoverContent_Shadcn_, - PopoverSeparator_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, -} from 'ui' -import SortRow from './SortRow' +import SortPopoverPrimitive from './SortPopoverPrimitive' export interface SortPopoverProps { portal?: boolean } const SortPopover = ({ portal = true }: SortPopoverProps) => { - const [open, setOpen] = useState(false) - const { urlSorts } = useTableSort() + const { urlSorts, onApplySorts } = useTableSort() - const btnText = - (urlSorts || []).length > 0 - ? `Sorted by ${urlSorts.length} rule${urlSorts.length > 1 ? 's' : ''}` - : 'Sort' + // Convert string[] to Sort[] + const sorts = useMemo(() => { + const { tableState } = window as any + const tableName = tableState?.table?.name || '' + return tableName ? formatSortURLParams(tableName, urlSorts ?? []) : [] + }, [urlSorts]) - return ( - - - - - - - - - ) + return } export default SortPopover - -export interface SortOverlayProps {} - -const SortOverlay = ({}: SortOverlayProps): JSX.Element => { - const snap = useTableEditorTableStateSnapshot() - const { urlSorts, onApplySorts } = useTableSort() - - const initialSorts = useMemo(() => { - const tableName = snap?.table?.name || '' - return tableName ? formatSortURLParams(tableName, urlSorts ?? []) : [] - }, [snap?.table?.name, urlSorts]) - - const [sorts, setSorts] = useState(initialSorts) - - useMemo(() => { - setSorts(initialSorts) - }, [initialSorts]) - - const columns = useMemo(() => { - if (!snap?.table?.columns) return [] - return snap.table.columns.filter((x) => { - if (x.dataType === 'json' || x.dataType === 'jsonb') return false - const found = sorts.find((y) => y.column == x.name) - return !found - }) - }, [snap?.table?.columns, sorts]) - - const dropdownOptions = useMemo(() => { - return columns?.map((x) => ({ value: x.name, label: x.name })) || [] - }, [columns]) - - function onAddSort(columnName: string | number) { - const currentTableName = snap.table?.name - if (currentTableName) { - setSorts([ - ...sorts, - { table: currentTableName, column: columnName as string, ascending: true }, - ]) - } - } - - const onDeleteSort = useCallback((column: string) => { - setSorts((currentSorts) => currentSorts.filter((sort) => sort.column !== column)) - }, []) - - const onToggleSort = useCallback((column: string, ascending: boolean) => { - setSorts((currentSorts) => { - const index = currentSorts.findIndex((x) => x.column === column) - if (index === -1) return currentSorts - const updatedSort = { ...currentSorts[index], ascending } - return [...currentSorts.slice(0, index), updatedSort, ...currentSorts.slice(index + 1)] - }) - }, []) - - const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => { - setSorts((currentSort) => { - if ( - dragIndex < 0 || - dragIndex >= currentSort.length || - hoverIndex < 0 || - hoverIndex >= currentSort.length - ) { - return currentSort - } - const itemToMove = currentSort[dragIndex] - const remainingItems = [ - ...currentSort.slice(0, dragIndex), - ...currentSort.slice(dragIndex + 1), - ] - return [ - ...remainingItems.slice(0, hoverIndex), - itemToMove, - ...remainingItems.slice(hoverIndex), - ] - }) - }, []) - - return ( - -
- {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

- )} -
- -
-
-
-
- ) -} diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx new file mode 100644 index 00000000000..55afebf8e22 --- /dev/null +++ b/apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx @@ -0,0 +1,112 @@ +import { isEqual } from 'lodash' +import { ChevronDown, List } from 'lucide-react' +import { useCallback, useMemo } from 'react' +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' + +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import type { Sort } from 'components/grid/types' +import { Button, PopoverSeparator_Shadcn_ } from 'ui' +import SortRow from './SortRow' +import { DropdownControl } from '../../common/DropdownControl' + +export interface SortPopoverContentProps { + sorts: Sort[] + initialSorts: Sort[] + onToggleSort: (column: string, ascending: boolean) => void + onDeleteSort: (column: string) => void + onDragSort: (dragIndex: number, hoverIndex: number) => void + onAddSort: (columnName: string | number) => void + onApplySorts: (sorts: Sort[]) => void + providedBackend?: any +} + +const SortPopoverContent = ({ + sorts, + initialSorts, + onToggleSort, + onDeleteSort, + onDragSort, + onAddSort, + onApplySorts, + providedBackend, +}: SortPopoverContentProps) => { + const snap = useTableEditorTableStateSnapshot() + + const columns = useMemo(() => { + if (!snap?.table?.columns) return [] + return snap.table.columns.filter((x) => { + if (x.dataType === 'json' || x.dataType === 'jsonb') return false + const found = sorts.find((y) => y.column == x.name) + return !found + }) + }, [snap?.table?.columns, sorts]) + + const dropdownOptions = useMemo(() => { + return columns?.map((x) => ({ value: x.name, label: x.name })) || [] + }, [columns]) + + const content = ( +
+ {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

+ )} +
+ +
+
+
+ ) + + // If providedBackend is passed, use that (for nested DndProvider contexts) + if (providedBackend) { + return content + } + + return {content} +} + +export default SortPopoverContent 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..c3f73d717e9 --- /dev/null +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -0,0 +1,177 @@ +import { isEqual } from 'lodash' +import { ChevronDown, List } from 'lucide-react' +import { useMemo, useCallback, useState } from 'react' +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' + +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import type { Sort } from 'components/grid/types' +import { + Button, + PopoverContent_Shadcn_, + PopoverSeparator_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' +import SortRow from './SortRow' +import { DropdownControl } from '../../common/DropdownControl' + +export interface SortPopoverPrimitiveProps { + buttonText?: string + sorts: Sort[] + onApplySorts: (sorts: Sort[]) => void + portal?: boolean + providedBackend?: any +} + +const SortPopoverPrimitive = ({ + buttonText, + sorts, + onApplySorts, + portal = true, + providedBackend, +}: SortPopoverPrimitiveProps) => { + const [open, setOpen] = useState(false) + const snap = useTableEditorTableStateSnapshot() + + // Internal state management + const [localSorts, setLocalSorts] = useState(sorts) + + // Update local state when sorts prop changes + useMemo(() => { + setLocalSorts(sorts) + }, [sorts]) + + const displayButtonText = + buttonText ?? + (sorts.length > 0 ? `Sorted by ${sorts.length} rule${sorts.length > 1 ? 's' : ''}` : 'Sort') + + 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]) + + const dropdownOptions = useMemo(() => { + return columns?.map((x) => ({ value: x.name, label: x.name })) || [] + }, [columns]) + + const onAddSort = (columnName: string | number) => { + const currentTableName = snap.table?.name + if (currentTableName) { + setLocalSorts([ + ...localSorts, + { table: currentTableName, column: columnName as string, ascending: true }, + ]) + } + } + + const onDeleteSort = useCallback((column: string) => { + setLocalSorts((currentSorts) => currentSorts.filter((sort) => sort.column !== 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)] + }) + }, []) + + 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), + ] + }) + }, []) + + 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 ( + + + + + + {providedBackend ? content : {content}} + + + ) +} + +export default SortPopoverPrimitive diff --git a/apps/studio/components/grid/components/header/sort/index.ts b/apps/studio/components/grid/components/header/sort/index.ts index 8f4094016a6..a28f916e587 100644 --- a/apps/studio/components/grid/components/header/sort/index.ts +++ b/apps/studio/components/grid/components/header/sort/index.ts @@ -1 +1,2 @@ export { default as SortPopover } from './SortPopover' +export { default as SortPopoverPrimitive } from './SortPopoverPrimitive' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx new file mode 100644 index 00000000000..5fead07a6fd --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx @@ -0,0 +1,17 @@ +import { Filter } from 'components/grid/types' +import FilterPopover from 'components/grid/components/header/filter/FilterPopover' +import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import { useEffect, useState } from 'react' + +interface FilterPopoverWrapperProps { + filters: string[] + onApplyFilters: (filters: Filter[]) => void + portal?: boolean +} + +const FilterPopoverWrapper = ({ filters, onApplyFilters, portal }: FilterPopoverWrapperProps) => { + return +} + +export default FilterPopoverWrapper 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..e873f620a08 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' 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/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx new file mode 100644 index 00000000000..d7ddca30c94 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx @@ -0,0 +1,17 @@ +import { Sort } from 'components/grid/types' +import { SortPopover } from 'components/grid/components/header/sort' +import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import { useEffect, useState } from 'react' + +interface SortPopoverWrapperProps { + sorts: string[] + onApplySorts: (sorts: Sort[]) => void + portal?: boolean +} + +const SortPopoverWrapper = ({ sorts, onApplySorts, portal }: SortPopoverWrapperProps) => { + return +} + +export default SortPopoverWrapper From bac942a27e3e477062913898c619d5b4d1c28800 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 18:14:06 +0800 Subject: [PATCH 05/10] Delete FilterPopoverWrapper.tsx --- .../ForeignRowSelector/FilterPopoverWrapper.tsx | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx deleted file mode 100644 index 5fead07a6fd..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/FilterPopoverWrapper.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Filter } from 'components/grid/types' -import FilterPopover from 'components/grid/components/header/filter/FilterPopover' -import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import { useEffect, useState } from 'react' - -interface FilterPopoverWrapperProps { - filters: string[] - onApplyFilters: (filters: Filter[]) => void - portal?: boolean -} - -const FilterPopoverWrapper = ({ filters, onApplyFilters, portal }: FilterPopoverWrapperProps) => { - return -} - -export default FilterPopoverWrapper From 822153e191c73f839caa87059a8c79f2662dd87d Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 18:14:32 +0800 Subject: [PATCH 06/10] Delete SortPopoverWrapper.tsx --- .../ForeignRowSelector/SortPopoverWrapper.tsx | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx deleted file mode 100644 index d7ddca30c94..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SortPopoverWrapper.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Sort } from 'components/grid/types' -import { SortPopover } from 'components/grid/components/header/sort' -import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import { useEffect, useState } from 'react' - -interface SortPopoverWrapperProps { - sorts: string[] - onApplySorts: (sorts: Sort[]) => void - portal?: boolean -} - -const SortPopoverWrapper = ({ sorts, onApplySorts, portal }: SortPopoverWrapperProps) => { - return -} - -export default SortPopoverWrapper From fe59b3217e5a3a9a898b04db6fe34486c7d44f2a Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 18:16:32 +0800 Subject: [PATCH 07/10] remove --- .../header/filter/FilterPopoverContent.tsx | 80 ------------- .../header/sort/SortPopoverContent.tsx | 112 ------------------ 2 files changed, 192 deletions(-) delete mode 100644 apps/studio/components/grid/components/header/filter/FilterPopoverContent.tsx delete mode 100644 apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverContent.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverContent.tsx deleted file mode 100644 index d524b946c59..00000000000 --- a/apps/studio/components/grid/components/header/filter/FilterPopoverContent.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { isEqual } from 'lodash' -import { Plus } from 'lucide-react' -import { KeyboardEvent, useCallback, useMemo } from 'react' - -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import type { Filter } from 'components/grid/types' -import { Button, PopoverSeparator_Shadcn_ } from 'ui' -import FilterRow from './FilterRow' - -export interface FilterPopoverContentProps { - filters: Filter[] - initialFilters: Filter[] - onChangeFilter: (index: number, filter: Filter) => void - onDeleteFilter: (index: number) => void - onAddFilter: () => void - onApplyFilters: (filters: Filter[]) => void -} - -const FilterPopoverContent = ({ - filters, - initialFilters, - onChangeFilter, - onDeleteFilter, - onAddFilter, - onApplyFilters, -}: FilterPopoverContentProps) => { - const snap = useTableEditorTableStateSnapshot() - - 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 - }) - 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

-
- )} -
- -
- - -
-
- ) -} - -export default FilterPopoverContent diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx deleted file mode 100644 index 55afebf8e22..00000000000 --- a/apps/studio/components/grid/components/header/sort/SortPopoverContent.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { isEqual } from 'lodash' -import { ChevronDown, List } from 'lucide-react' -import { useCallback, useMemo } from 'react' -import { DndProvider } from 'react-dnd' -import { HTML5Backend } from 'react-dnd-html5-backend' - -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import type { Sort } from 'components/grid/types' -import { Button, PopoverSeparator_Shadcn_ } from 'ui' -import SortRow from './SortRow' -import { DropdownControl } from '../../common/DropdownControl' - -export interface SortPopoverContentProps { - sorts: Sort[] - initialSorts: Sort[] - onToggleSort: (column: string, ascending: boolean) => void - onDeleteSort: (column: string) => void - onDragSort: (dragIndex: number, hoverIndex: number) => void - onAddSort: (columnName: string | number) => void - onApplySorts: (sorts: Sort[]) => void - providedBackend?: any -} - -const SortPopoverContent = ({ - sorts, - initialSorts, - onToggleSort, - onDeleteSort, - onDragSort, - onAddSort, - onApplySorts, - providedBackend, -}: SortPopoverContentProps) => { - const snap = useTableEditorTableStateSnapshot() - - const columns = useMemo(() => { - if (!snap?.table?.columns) return [] - return snap.table.columns.filter((x) => { - if (x.dataType === 'json' || x.dataType === 'jsonb') return false - const found = sorts.find((y) => y.column == x.name) - return !found - }) - }, [snap?.table?.columns, sorts]) - - const dropdownOptions = useMemo(() => { - return columns?.map((x) => ({ value: x.name, label: x.name })) || [] - }, [columns]) - - const content = ( -
- {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

- )} -
- -
-
-
- ) - - // If providedBackend is passed, use that (for nested DndProvider contexts) - if (providedBackend) { - return content - } - - return {content} -} - -export default SortPopoverContent From 01c089256036a8eae1ec0b4d2b5c01f89b2238b5 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 18:26:20 +0800 Subject: [PATCH 08/10] Create README.md --- apps/studio/components/grid/hooks/README.md | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 apps/studio/components/grid/hooks/README.md diff --git a/apps/studio/components/grid/hooks/README.md b/apps/studio/components/grid/hooks/README.md new file mode 100644 index 00000000000..f588439abd1 --- /dev/null +++ b/apps/studio/components/grid/hooks/README.md @@ -0,0 +1,126 @@ +# Table Filtering and Sorting Architecture + +## Overview + +The table filtering and sorting system uses a URL-based state persistence pattern combined with custom hooks that abstract the implementation details from consuming components. This architecture provides several benefits: + +- **Persistent state**: Filters and sorts are stored in URL parameters, enabling bookmarking and sharing +- **Separation of concerns**: Logic is separated from UI components +- **Draft-then-apply pattern**: UI components maintain draft state until explicitly applied + +## Core Hooks + +### `useTableFilter` + +```typescript +// Returns: { filters, urlFilters, onApplyFilters } +``` + +The `useTableFilter` hook manages filter state with these responsibilities: + +- Retrieves raw filter parameters from URL +- Formats filter parameters into usable Filter[] objects +- Provides a callback to apply new filters +- Persists changes to URL parameters +- Triggers side effects through `saveFiltersAndTriggerSideEffects` + +Key design aspects: + +- No direct snapshot interaction, keeping it focused solely on filter management +- Uses URL parameters as the source of truth +- Forwards filter changes to URL and triggers application-specific side effects + +### `useTableSort` + +```typescript +// Returns: { sorts, urlSorts, onApplySorts } +``` + +The `useTableSort` hook manages sort state with these responsibilities: + +- Retrieves raw filter parameters from URL +- Formats sort parameters into usable Sort[] objects (needs table name) +- Provides a callback to apply new sorts +- Persists changes to URL parameters +- Triggers side effects through `saveSortsAndTriggerSideEffects` + +Key design aspects: + +- Uses safe snapshot ONLY to get table name for formatting +- Handles applying table name to sort objects +- Maintains URL parameters as source of truth +- Forwards sort changes to URL and triggers application-specific side effects + +### `useSafeTableEditorSnapshot` + +```typescript +// Returns: Partial +``` + +This hook exists is to satisfy React's rules of hooks: + +React hooks CANNOT be called conditionally. Since `useTableSort` is a hook that needs table state, we cannot write: + +```typescript +// THIS VIOLATES REACT'S RULES - NOT ALLOWED +function useTableSort() { + const { sorts: urlSorts } = useTableEditorFiltersSort() + + // Conditional hook usage is forbidden in React! + const snap = hasTableContext ? useTableEditorTableStateSnapshot() : null + + // ...rest of hook +} +``` + +Instead, we created `useSafeTableEditorSnapshot` which unconditionally calls the original snapshot hook and handles error cases. This allows `useTableSort` to always call the hook safely regardless of context. + +## Component Implementation + +### FilterPopoverPrimitive and SortPopoverPrimitive + +These components follow a "draft and apply" pattern: + +1. **Local state management**: Both components maintain a local state copy of filters/sorts +2. **Edit operations**: Changes like adding, modifying, or deleting are made to the local state +3. **Apply operations**: Only when the user clicks "Apply" are the changes committed via the callback +4. **Synchronization**: Local state is synchronized with props when external changes occur + +## Data Flow + +1. URL parameters store the raw filter/sort state +2. Hooks read and format these parameters into usable objects +3. UI components receive formatted objects and callbacks +4. Components maintain draft state for editing +5. When "Apply" is clicked, callbacks update URL parameters +6. Side effects are triggered via dedicated save hooks + +## Component Usage + +Components using these hooks should follow this pattern: + +```tsx +function TableComponent() { + // Get filter data and callbacks + const { filters, onApplyFilters } = useTableFilter() + + // Get sort data and callbacks + const { sorts, onApplySorts } = useTableSort() + + return ( + <> + + + + + {/* 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 From 8184915e01a39eb4f832fbad38011bd70392e07a Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 18:27:04 +0800 Subject: [PATCH 09/10] Update README.md --- apps/studio/components/grid/hooks/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/grid/hooks/README.md b/apps/studio/components/grid/hooks/README.md index f588439abd1..ffbb3c5d97f 100644 --- a/apps/studio/components/grid/hooks/README.md +++ b/apps/studio/components/grid/hooks/README.md @@ -1,4 +1,4 @@ -# Table Filtering and Sorting Architecture +# Table Filtering and Sorting Developer notes ## Overview From 11cfa01c75cb0ff7e7c7e09a6c15f60bad581e8e Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 23 Apr 2025 19:01:25 +0800 Subject: [PATCH 10/10] fix sort popover issues --- .../components/header/sort/SortPopover.tsx | 9 +- .../header/sort/SortPopoverPrimitive.tsx | 101 ++++++++++++++---- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/apps/studio/components/grid/components/header/sort/SortPopover.tsx b/apps/studio/components/grid/components/header/sort/SortPopover.tsx index 1c5505dcc0e..6e94d073bd6 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopover.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopover.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils' import { useTableSort } from 'components/grid/hooks/useTableSort' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import SortPopoverPrimitive from './SortPopoverPrimitive' export interface SortPopoverProps { @@ -10,13 +11,13 @@ export interface SortPopoverProps { const SortPopover = ({ portal = true }: SortPopoverProps) => { const { urlSorts, onApplySorts } = useTableSort() + const tableState = useTableEditorTableStateSnapshot() + const tableName = tableState?.table?.name || '' // Convert string[] to Sort[] const sorts = useMemo(() => { - const { tableState } = window as any - const tableName = tableState?.table?.name || '' - return tableName ? formatSortURLParams(tableName, urlSorts ?? []) : [] - }, [urlSorts]) + return tableName && urlSorts ? formatSortURLParams(tableName, urlSorts) : [] + }, [tableName, urlSorts]) return } diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx index c3f73d717e9..dd720d9f356 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -1,11 +1,9 @@ import { isEqual } from 'lodash' import { ChevronDown, List } from 'lucide-react' -import { useMemo, useCallback, useState } from 'react' -import { DndProvider } from 'react-dnd' -import { HTML5Backend } from 'react-dnd-html5-backend' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import type { Sort } from 'components/grid/types' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, PopoverContent_Shadcn_, @@ -13,39 +11,66 @@ import { PopoverTrigger_Shadcn_, Popover_Shadcn_, } from 'ui' -import SortRow from './SortRow' import { DropdownControl } from '../../common/DropdownControl' +import SortRow from './SortRow' export interface SortPopoverPrimitiveProps { buttonText?: string sorts: Sort[] onApplySorts: (sorts: Sort[]) => void portal?: boolean - providedBackend?: any } +/** + * SortPopoverPrimitive - A component for sorting table columns + * + * This component maintains a draft state of sorts while editing, then applies + * them to the parent component when "Apply" is clicked. + * + * To avoid issues with drag-and-drop reconciliation, we use a special sync mechanism + * that properly detects external vs. internal updates. + */ const SortPopoverPrimitive = ({ buttonText, sorts, onApplySorts, portal = true, - providedBackend, }: SortPopoverPrimitiveProps) => { const [open, setOpen] = useState(false) const snap = useTableEditorTableStateSnapshot() - // Internal state management + // Local state for draft sorts const [localSorts, setLocalSorts] = useState(sorts) - // Update local state when sorts prop changes - useMemo(() => { - setLocalSorts(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 ?? - (sorts.length > 0 ? `Sorted by ${sorts.length} rule${sorts.length > 1 ? 's' : ''}` : 'Sort') + (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) => { @@ -55,10 +80,12 @@ const SortPopoverPrimitive = ({ }) }, [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) { @@ -69,10 +96,12 @@ const SortPopoverPrimitive = ({ } } + // 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) @@ -82,6 +111,7 @@ const SortPopoverPrimitive = ({ }) }, []) + // Handle drag-and-drop reordering const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => { setLocalSorts((currentSort) => { if ( @@ -105,11 +135,46 @@ const SortPopoverPrimitive = ({ }) }, []) + // 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) => ( All columns have been added

)}
-
@@ -163,12 +224,12 @@ const SortPopoverPrimitive = ({ return ( - - {providedBackend ? content : {content}} + {content} )