Files
supabase/apps/studio/components/grid/SupabaseGrid.utils.ts
Ivan Vasilov 40c947ebfb fix: Handle non existant columns when sorting tables (#46741)
When a user has sorted by some column in the Table Editor and the column
is deleted, the sort data is wrong so it causes issues. In the general
view in the Table Editor, the error is handled by removing the sort key
when a specific error is detected but it can still happen in
ForeignRowSelector.

To test:
1. Have 2 tables with references between them.
2. In the `sessionStorage`, under the `supabase_grid-<ref>` key, update
the sort key to a non-existant column for a table.
3. Try to open the `ForeignRowSelector` for that table by clicking on a
cell in the referencing column.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Sorting now validates referenced columns and ignores invalid sort
entries.
* Local sort restoration and UI sort application now derive sorts from
the original table context for more consistent behavior across editors
and popovers.
* Prefetch logic uses the resolved table context when falling back to
saved sorts.

* **Tests**
* Added cases for malformed and out-of-scope sort parameters to prevent
regressions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-09 12:30:13 +02:00

351 lines
11 KiB
TypeScript

import AwesomeDebouncePromise from 'awesome-debounce-promise'
import { safeLocalStorage, safeSessionStorage } from 'common'
import { compact } from 'lodash'
import { useSearchParams } from 'next/navigation'
import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useEffect, useMemo } from 'react'
import {
CalculatedColumn,
CellKeyboardEvent,
CellKeyDownArgs,
RowsChangeData,
} from 'react-data-grid'
import { toast } from 'sonner'
import { copyToClipboard } from 'ui'
import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
import { STORAGE_KEY_PREFIX } from './constants'
import type { Sort, SupaColumn, SupaRow, SupaTable } from './types'
import { formatClipboardValue } from './utils/common'
import { isBoolColumn } from './utils/types'
import type { Filter, SavedState } from '@/components/grid/types'
import { Entity, isTableLike } from '@/data/table-editor/table-editor-types'
import { BASE_PATH } from '@/lib/constants'
import { eventMatchesAnyShortcut } from '@/state/shortcuts/matchEvent'
import { tableEditorRegistry } from '@/state/shortcuts/registry/table-editor'
export function formatSortURLParams(
// Should match the Entity type.
table: { name: string; columns: { name: string }[] },
sort?: string[]
): Sort[] {
if (Array.isArray(sort)) {
return compact(
sort.map((s) => {
const [column, order] = s.split(':')
// Reject any possible malformed sort param
if (!column || !order) return undefined
// if the sort column name doesn't exist in the table, reject it as well to avoid confusion
if (table.columns.find((c) => c.name === column) === undefined) return undefined
else return { table: table.name, column, ascending: order === 'asc' }
})
)
}
return []
}
export function sortsToUrlParams(sorts: { column: string; ascending?: boolean }[]) {
return sorts.map((sort) => `${sort.column}:${sort.ascending ? 'asc' : 'desc'}`)
}
export function formatFilterURLParams(filter?: string[]): Filter[] {
return (
Array.isArray(filter)
? filter
.map((f) => {
const [column, operatorAbbrev, ...value] = f.split(':')
// Allow usage of : in value, so join them back after spliting
const formattedValue = value.join(':')
const operator = FilterOperatorOptions.find(
(option) => option.abbrev === operatorAbbrev
)
// Reject any possible malformed filter param
if (!column || !operatorAbbrev || !operator) return undefined
else return { column, operator: operator.value, value: formattedValue || '' }
})
.filter((f) => f !== undefined)
: []
) as Filter[]
}
export function filtersToUrlParams(
filters: { column: string | Array<string>; operator: string; value: string }[]
) {
return filters.map((filter) => {
const selectedOperator = FilterOperatorOptions.find(
(option) => option.value === filter.operator
)
return `${filter.column}:${selectedOperator?.abbrev}:${filter.value}`
})
}
export function parseSupaTable(table: Entity): SupaTable {
const columns = table.columns
const primaryKeys = isTableLike(table) ? table.primary_keys : []
const uniqueIndexes = isTableLike(table) ? table.unique_indexes : []
const relationships = isTableLike(table) ? table.relationships : []
const supaColumns: SupaColumn[] = columns.map((column) => {
const temp = {
position: column.ordinal_position,
name: column.name,
defaultValue: column.default_value as string | null | undefined,
dataType: column.data_type,
format: column.format,
isPrimaryKey: false,
isIdentity: column.is_identity,
isGeneratable: column.identity_generation == 'BY DEFAULT',
isNullable: column.is_nullable,
isUpdatable: column.is_updatable,
enum: column.enums,
comment: column.comment,
foreignKey: {
targetTableSchema: null as string | null,
targetTableName: null as string | null,
targetColumnName: null as string | null,
deletionAction: undefined as string | undefined,
updateAction: undefined as string | undefined,
},
}
const primaryKey = primaryKeys.find((pk) => pk.name == column.name)
temp.isPrimaryKey = !!primaryKey
const relationship = relationships.find((relation) => {
return (
relation.source_schema === column.schema &&
relation.source_table_name === column.table &&
relation.source_column_name === column.name
)
})
if (relationship) {
temp.foreignKey.targetTableSchema = relationship.target_table_schema
temp.foreignKey.targetTableName = relationship.target_table_name
temp.foreignKey.targetColumnName = relationship.target_column_name
temp.foreignKey.deletionAction = relationship.deletion_action
temp.foreignKey.updateAction = relationship.update_action
}
return temp
})
return {
id: table.id,
name: table.name,
comment: table.comment,
schema: table.schema,
type: table.entity_type,
columns: supaColumns,
estimateRowCount: isTableLike(table) ? table.live_rows_estimate : 0,
primaryKey: primaryKeys?.length > 0 ? primaryKeys.map((col) => col.name) : undefined,
uniqueIndexes:
!!uniqueIndexes && uniqueIndexes.length > 0
? uniqueIndexes.map(({ columns }) => columns)
: undefined,
}
}
export function getStorageKey(prefix: string, ref: string) {
return `${prefix}_${ref}`
}
export function loadTableEditorStateFromLocalStorage(
projectRef: string,
tableId: number
): SavedState | undefined {
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
// Prefer sessionStorage (scoped to current tab) over localStorage
const jsonStr = safeSessionStorage.getItem(storageKey) ?? safeLocalStorage.getItem(storageKey)
if (!jsonStr) return
const json = JSON.parse(jsonStr)
return json[tableId]
}
/**
* Builds a table editor URL with the given project reference, table ID. It will load the saved state from local storage
* and add the sort and filter parameters to the URL.
*/
export function buildTableEditorUrl({
projectRef = 'default',
tableId,
schema,
}: {
projectRef?: string
tableId: number
schema?: string
}) {
const url = new URL(`${BASE_PATH}/project/${projectRef}/editor/${tableId}`, location.origin)
// If the schema is provided, add it to the URL so that the left sidebar is opened to the correct schema
if (schema) {
url.searchParams.set('schema', schema)
}
const savedState = loadTableEditorStateFromLocalStorage(projectRef, tableId)
if (savedState?.sorts && savedState.sorts.length > 0) {
savedState.sorts?.forEach((sort) => url.searchParams.append('sort', sort))
}
if (savedState?.filters && savedState.filters.length > 0) {
savedState.filters?.forEach((filter) => url.searchParams.append('filter', filter))
}
return url.toString()
}
export function saveTableEditorStateToLocalStorage({
projectRef,
tableId,
gridColumns,
sorts,
filters,
}: {
projectRef: string
tableId: number
gridColumns?: CalculatedColumn<any, any>[]
sorts?: string[]
filters?: string[]
}) {
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
const savedStr = safeSessionStorage.getItem(storageKey) ?? safeLocalStorage.getItem(storageKey)
const config = {
...(gridColumns !== undefined && { gridColumns }),
...(sorts !== undefined && { sorts: sorts.filter((sort) => sort !== '') }),
...(filters !== undefined && { filters: filters.filter((filter) => filter !== '') }),
}
let savedJson
if (savedStr) {
savedJson = JSON.parse(savedStr)
const previousConfig = savedJson[tableId]
savedJson = { ...savedJson, [tableId]: { ...previousConfig, ...config } }
} else {
savedJson = { [tableId]: config }
}
// Save to both localStorage and sessionStorage so it's consistent to current tab
safeLocalStorage.setItem(storageKey, JSON.stringify(savedJson))
safeSessionStorage.setItem(storageKey, JSON.stringify(savedJson))
}
export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromise(
saveTableEditorStateToLocalStorage,
500
)
function getLatestParams() {
const queryParams = new URLSearchParams(window.location.search)
const sort = queryParams.getAll('sort')
const filter = queryParams.getAll('filter')
return { sort, filter }
}
export function useSyncTableEditorStateFromLocalStorageWithUrl({
projectRef,
table,
}: {
projectRef: string | undefined
table: Entity | undefined
}) {
// Warning: nuxt url state often fails to update to changes to URL
useQueryStates(
{
sort: parseAsNativeArrayOf(parseAsString),
filter: parseAsNativeArrayOf(parseAsString),
},
{
history: 'replace',
}
)
// Use nextjs useSearchParams to get the latest URL params
const searchParams = useSearchParams()
const urlParams = useMemo(() => {
const sort = searchParams?.getAll('sort') ?? []
const filter = searchParams?.getAll('filter') ?? []
return { sort, filter }
}, [searchParams])
useEffect(() => {
if (!projectRef || !table) {
return
}
// `urlParams` from `useQueryStates` can be stale so always get the latest from the URL
const latestUrlParams = getLatestParams()
saveTableEditorStateToLocalStorage({
projectRef,
tableId: table.id,
sorts: latestUrlParams.sort,
filters: latestUrlParams.filter,
})
}, [urlParams, table, projectRef])
}
export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
args: CellKeyDownArgs<TRow, unknown>,
event: CellKeyboardEvent,
context?: {
rows: TRow[]
columns: SupaColumn[]
onRowsChange: (rows: TRow[], data: RowsChangeData<TRow, unknown>) => void
}
) => {
const { mode, column, row, rowIdx } = args
if (mode !== 'SELECT') return
const key = event.key.toLowerCase()
if (key === 'c' && (event.metaKey || event.ctrlKey)) {
if (window.getSelection()?.isCollapsed === false) return
const value = formatClipboardValue(row[column.key] ?? '')
event.preventDefault()
event.preventGridDefault()
void copyToClipboard(value, () => {
toast.success('Copied cell value to clipboard')
})
return
}
// Let registered shortcuts win over rdg's "type a key to enter edit mode" default,
// unless a printable key enters edit mode.
if (eventMatchesAnyShortcut(event.nativeEvent, tableEditorRegistry)) {
if (
event.key.length === 1 &&
event.key !== ' ' &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
column.renderEditCell != null
) {
event.stopPropagation()
} else {
event.preventGridDefault()
return
}
}
// Toggle boolean cells with T/F when no modifier keys are pressed.
if (context === undefined) return
if (event.altKey || event.ctrlKey || event.metaKey || (key !== 't' && key !== 'f')) return
const supaColumn = context.columns.find((c) => c.name === column.key)
if (
supaColumn === undefined ||
!isBoolColumn(supaColumn.dataType) ||
column.renderEditCell == null
) {
return
}
event.preventDefault()
event.preventGridDefault()
const nextValue = key === 't'
if (row[column.key] === nextValue) return
const updatedRows = [...context.rows]
updatedRows[rowIdx] = { ...row, [column.key]: nextValue }
context.onRowsChange(updatedRows, { indexes: [rowIdx], column })
}