mirror of
https://github.com/supabase/supabase.git
synced 2026-06-16 11:58:16 +08:00
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 -->
351 lines
11 KiB
TypeScript
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 })
|
|
}
|