diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index e1c97656c5c..5b506a0dcc4 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' @@ -96,9 +94,6 @@ const DefaultHeader = () => { const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') const { mutate: sendEvent } = useSendEventMutation() - const { onApplyFilters, urlFilters } = useTableFilter() - const { onApplySorts, urlSorts } = useTableSort() - const onAddRow = snap.editable && (snap.table.columns ?? []).length > 0 ? tableEditorSnap.onAddRow : undefined const onAddColumn = snap.editable ? tableEditorSnap.onAddColumn : undefined @@ -109,8 +104,8 @@ const DefaultHeader = () => { return (
- - + +
{canAddNew && ( 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/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..6e94d073bd6 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopover.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopover.tsx @@ -1,182 +1,25 @@ -import { DndProvider } from 'react-dnd' -import { HTML5Backend } from 'react-dnd-html5-backend' -import { isEqual } from 'lodash' -import { ChevronDown, List, Plus } from 'lucide-react' -import { useCallback, useMemo, useState } from 'react' +import { useMemo } from 'react' -import { DropdownControl } from '../../common/DropdownControl' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import type { Sort } from 'components/grid/types' import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils' import { useTableSort } from 'components/grid/hooks/useTableSort' -import { - Button, - PopoverContent_Shadcn_, - PopoverSeparator_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, -} from 'ui' -import SortRow from './SortRow' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import SortPopoverPrimitive from './SortPopoverPrimitive' export interface SortPopoverProps { portal?: boolean } const SortPopover = ({ portal = true }: SortPopoverProps) => { - const [open, setOpen] = useState(false) - const { urlSorts } = useTableSort() + const { urlSorts, onApplySorts } = useTableSort() + const tableState = useTableEditorTableStateSnapshot() + const tableName = tableState?.table?.name || '' - const btnText = - (urlSorts || []).length > 0 - ? `Sorted by ${urlSorts.length} rule${urlSorts.length > 1 ? 's' : ''}` - : 'Sort' + // Convert string[] to Sort[] + const sorts = useMemo(() => { + return tableName && urlSorts ? formatSortURLParams(tableName, urlSorts) : [] + }, [tableName, urlSorts]) - return ( - - - - - - - - - ) + 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/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx new file mode 100644 index 00000000000..dd720d9f356 --- /dev/null +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -0,0 +1,238 @@ +import { isEqual } from 'lodash' +import { ChevronDown, List } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { Sort } from 'components/grid/types' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import { + Button, + PopoverContent_Shadcn_, + PopoverSeparator_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' +import { DropdownControl } from '../../common/DropdownControl' +import SortRow from './SortRow' + +export interface SortPopoverPrimitiveProps { + buttonText?: string + sorts: Sort[] + onApplySorts: (sorts: Sort[]) => void + portal?: boolean +} + +/** + * SortPopoverPrimitive - A component for sorting table columns + * + * This component maintains a draft state of sorts while editing, then applies + * them to the parent component when "Apply" is clicked. + * + * To avoid issues with drag-and-drop reconciliation, we use a special sync mechanism + * that properly detects external vs. internal updates. + */ +const SortPopoverPrimitive = ({ + buttonText, + sorts, + onApplySorts, + portal = true, +}: SortPopoverPrimitiveProps) => { + const [open, setOpen] = useState(false) + const snap = useTableEditorTableStateSnapshot() + + // Local state for draft sorts + const [localSorts, setLocalSorts] = useState(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} + + + ) +} + +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/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/README.md b/apps/studio/components/grid/hooks/README.md new file mode 100644 index 00000000000..ffbb3c5d97f --- /dev/null +++ b/apps/studio/components/grid/hooks/README.md @@ -0,0 +1,126 @@ +# Table Filtering and Sorting Developer notes + +## Overview + +The table filtering and sorting system uses a URL-based state persistence pattern combined with custom hooks that abstract the implementation details from consuming components. This architecture provides several benefits: + +- **Persistent state**: Filters and sorts are stored in URL parameters, enabling bookmarking and sharing +- **Separation of concerns**: Logic is separated from UI components +- **Draft-then-apply pattern**: UI components maintain draft state until explicitly applied + +## Core Hooks + +### `useTableFilter` + +```typescript +// Returns: { filters, urlFilters, onApplyFilters } +``` + +The `useTableFilter` hook manages filter state with these responsibilities: + +- Retrieves raw filter parameters from URL +- Formats filter parameters into usable Filter[] objects +- Provides a callback to apply new filters +- Persists changes to URL parameters +- Triggers side effects through `saveFiltersAndTriggerSideEffects` + +Key design aspects: + +- No direct snapshot interaction, keeping it focused solely on filter management +- Uses URL parameters as the source of truth +- Forwards filter changes to URL and triggers application-specific side effects + +### `useTableSort` + +```typescript +// Returns: { sorts, urlSorts, onApplySorts } +``` + +The `useTableSort` hook manages sort state with these responsibilities: + +- Retrieves raw filter parameters from URL +- Formats sort parameters into usable Sort[] objects (needs table name) +- Provides a callback to apply new sorts +- Persists changes to URL parameters +- Triggers side effects through `saveSortsAndTriggerSideEffects` + +Key design aspects: + +- Uses safe snapshot ONLY to get table name for formatting +- Handles applying table name to sort objects +- Maintains URL parameters as source of truth +- Forwards sort changes to URL and triggers application-specific side effects + +### `useSafeTableEditorSnapshot` + +```typescript +// Returns: Partial +``` + +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 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, } } 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 = ({
- - +