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; 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[] 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 = ( args: CellKeyDownArgs, event: CellKeyboardEvent, context?: { rows: TRow[] columns: SupaColumn[] onRowsChange: (rows: TRow[], data: RowsChangeData) => 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 }) }