BrivenGrid.utils.ts344 lines · main
1// @ts-nocheck
2import AwesomeDebouncePromise from 'awesome-debounce-promise'
3import { compact } from 'lodash'
4import { useSearchParams } from 'next/navigation'
5import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs'
6import { useEffect, useMemo } from 'react'
7import {
8 CalculatedColumn,
9 CellKeyboardEvent,
10 CellKeyDownArgs,
11 RowsChangeData,
12} from 'react-data-grid'
13import { toast } from 'sonner'
14import { copyToClipboard } from 'ui'
15
16import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
17import { STORAGE_KEY_PREFIX } from './constants'
18import type { Sort, SupaColumn, SupaRow, SupaTable } from './types'
19import { formatClipboardValue } from './utils/common'
20import { isBoolColumn } from './utils/types'
21import type { Filter, SavedState } from '@/components/grid/types'
22import { Entity, isTableLike } from '@/data/table-editor/table-editor-types'
23import { BASE_PATH } from '@/lib/constants'
24import { eventMatchesAnyShortcut } from '@/state/shortcuts/matchEvent'
25import { tableEditorRegistry } from '@/state/shortcuts/registry/table-editor'
26
27export function formatSortURLParams(tableName: string, sort?: string[]): Sort[] {
28 if (Array.isArray(sort)) {
29 return compact(
30 sort.map((s) => {
31 const [column, order] = s.split(':')
32 // Reject any possible malformed sort param
33 if (!column || !order) return undefined
34 else return { table: tableName, column, ascending: order === 'asc' }
35 })
36 )
37 }
38 return []
39}
40
41export function sortsToUrlParams(sorts: { column: string; ascending?: boolean }[]) {
42 return sorts.map((sort) => `${sort.column}:${sort.ascending ? 'asc' : 'desc'}`)
43}
44
45export function formatFilterURLParams(filter?: string[]): Filter[] {
46 return (
47 Array.isArray(filter)
48 ? filter
49 .map((f) => {
50 const [column, operatorAbbrev, ...value] = f.split(':')
51
52 // Allow usage of : in value, so join them back after spliting
53 const formattedValue = value.join(':')
54 const operator = FilterOperatorOptions.find(
55 (option) => option.abbrev === operatorAbbrev
56 )
57 // Reject any possible malformed filter param
58 if (!column || !operatorAbbrev || !operator) return undefined
59 else return { column, operator: operator.value, value: formattedValue || '' }
60 })
61 .filter((f) => f !== undefined)
62 : []
63 ) as Filter[]
64}
65
66export function filtersToUrlParams(
67 filters: { column: string | Array<string>; operator: string; value: string }[]
68) {
69 return filters.map((filter) => {
70 const selectedOperator = FilterOperatorOptions.find(
71 (option) => option.value === filter.operator
72 )
73
74 return `${filter.column}:${selectedOperator?.abbrev}:${filter.value}`
75 })
76}
77
78export function parseSupaTable(table: Entity): SupaTable {
79 const columns = table.columns
80 const primaryKeys = isTableLike(table) ? table.primary_keys : []
81 const uniqueIndexes = isTableLike(table) ? table.unique_indexes : []
82 const relationships = isTableLike(table) ? table.relationships : []
83
84 const supaColumns: SupaColumn[] = columns.map((column) => {
85 const temp = {
86 position: column.ordinal_position,
87 name: column.name,
88 defaultValue: column.default_value as string | null | undefined,
89 dataType: column.data_type,
90 format: column.format,
91 isPrimaryKey: false,
92 isIdentity: column.is_identity,
93 isGeneratable: column.identity_generation == 'BY DEFAULT',
94 isNullable: column.is_nullable,
95 isUpdatable: column.is_updatable,
96 enum: column.enums,
97 comment: column.comment,
98 foreignKey: {
99 targetTableSchema: null as string | null,
100 targetTableName: null as string | null,
101 targetColumnName: null as string | null,
102 deletionAction: undefined as string | undefined,
103 updateAction: undefined as string | undefined,
104 },
105 }
106 const primaryKey = primaryKeys.find((pk) => pk.name == column.name)
107 temp.isPrimaryKey = !!primaryKey
108
109 const relationship = relationships.find((relation) => {
110 return (
111 relation.source_schema === column.schema &&
112 relation.source_table_name === column.table &&
113 relation.source_column_name === column.name
114 )
115 })
116 if (relationship) {
117 temp.foreignKey.targetTableSchema = relationship.target_table_schema
118 temp.foreignKey.targetTableName = relationship.target_table_name
119 temp.foreignKey.targetColumnName = relationship.target_column_name
120 temp.foreignKey.deletionAction = relationship.deletion_action
121 temp.foreignKey.updateAction = relationship.update_action
122 }
123 return temp
124 })
125
126 return {
127 id: table.id,
128 name: table.name,
129 comment: table.comment,
130 schema: table.schema,
131 type: table.entity_type,
132 columns: supaColumns,
133 estimateRowCount: isTableLike(table) ? table.live_rows_estimate : 0,
134 primaryKey: primaryKeys?.length > 0 ? primaryKeys.map((col) => col.name) : undefined,
135 uniqueIndexes:
136 !!uniqueIndexes && uniqueIndexes.length > 0
137 ? uniqueIndexes.map(({ columns }) => columns)
138 : undefined,
139 }
140}
141
142export function getStorageKey(prefix: string, ref: string) {
143 return `${prefix}_${ref}`
144}
145
146export function loadTableEditorStateFromLocalStorage(
147 projectRef: string,
148 tableId: number
149): SavedState | undefined {
150 const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
151 // Prefer sessionStorage (scoped to current tab) over localStorage
152 const jsonStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey)
153 if (!jsonStr) return
154 const json = JSON.parse(jsonStr)
155 return json[tableId]
156}
157
158/**
159 * Builds a table editor URL with the given project reference, table ID. It will load the saved state from local storage
160 * and add the sort and filter parameters to the URL.
161 */
162export function buildTableEditorUrl({
163 projectRef = 'default',
164 tableId,
165 schema,
166}: {
167 projectRef?: string
168 tableId: number
169 schema?: string
170}) {
171 const url = new URL(`${BASE_PATH}/project/${projectRef}/editor/${tableId}`, location.origin)
172
173 // If the schema is provided, add it to the URL so that the left sidebar is opened to the correct schema
174 if (schema) {
175 url.searchParams.set('schema', schema)
176 }
177
178 const savedState = loadTableEditorStateFromLocalStorage(projectRef, tableId)
179 if (savedState?.sorts && savedState.sorts.length > 0) {
180 savedState.sorts?.forEach((sort) => url.searchParams.append('sort', sort))
181 }
182 if (savedState?.filters && savedState.filters.length > 0) {
183 savedState.filters?.forEach((filter) => url.searchParams.append('filter', filter))
184 }
185 return url.toString()
186}
187
188export function saveTableEditorStateToLocalStorage({
189 projectRef,
190 tableId,
191 gridColumns,
192 sorts,
193 filters,
194}: {
195 projectRef: string
196 tableId: number
197 gridColumns?: CalculatedColumn<any, any>[]
198 sorts?: string[]
199 filters?: string[]
200}) {
201 const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
202 const savedStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey)
203
204 const config = {
205 ...(gridColumns !== undefined && { gridColumns }),
206 ...(sorts !== undefined && { sorts: sorts.filter((sort) => sort !== '') }),
207 ...(filters !== undefined && { filters: filters.filter((filter) => filter !== '') }),
208 }
209
210 let savedJson
211 if (savedStr) {
212 savedJson = JSON.parse(savedStr)
213 const previousConfig = savedJson[tableId]
214 savedJson = { ...savedJson, [tableId]: { ...previousConfig, ...config } }
215 } else {
216 savedJson = { [tableId]: config }
217 }
218 // Save to both localStorage and sessionStorage so it's consistent to current tab
219 localStorage.setItem(storageKey, JSON.stringify(savedJson))
220 sessionStorage.setItem(storageKey, JSON.stringify(savedJson))
221}
222
223export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromise(
224 saveTableEditorStateToLocalStorage,
225 500
226)
227
228function getLatestParams() {
229 const queryParams = new URLSearchParams(window.location.search)
230 const sort = queryParams.getAll('sort')
231 const filter = queryParams.getAll('filter')
232 return { sort, filter }
233}
234
235export function useSyncTableEditorStateFromLocalStorageWithUrl({
236 projectRef,
237 table,
238}: {
239 projectRef: string | undefined
240 table: Entity | undefined
241}) {
242 // Warning: nuxt url state often fails to update to changes to URL
243 useQueryStates(
244 {
245 sort: parseAsNativeArrayOf(parseAsString),
246 filter: parseAsNativeArrayOf(parseAsString),
247 },
248 {
249 history: 'replace',
250 }
251 )
252 // Use nextjs useSearchParams to get the latest URL params
253 const searchParams = useSearchParams()
254 const urlParams = useMemo(() => {
255 const sort = searchParams?.getAll('sort') ?? []
256 const filter = searchParams?.getAll('filter') ?? []
257 return { sort, filter }
258 }, [searchParams])
259
260 useEffect(() => {
261 if (!projectRef || !table) {
262 return
263 }
264
265 // `urlParams` from `useQueryStates` can be stale so always get the latest from the URL
266 const latestUrlParams = getLatestParams()
267
268 saveTableEditorStateToLocalStorage({
269 projectRef,
270 tableId: table.id,
271 sorts: latestUrlParams.sort,
272 filters: latestUrlParams.filter,
273 })
274 }, [urlParams, table, projectRef])
275}
276
277export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
278 args: CellKeyDownArgs<TRow, unknown>,
279 event: CellKeyboardEvent,
280 context?: {
281 rows: TRow[]
282 columns: SupaColumn[]
283 onRowsChange: (rows: TRow[], data: RowsChangeData<TRow, unknown>) => void
284 }
285) => {
286 const { mode, column, row, rowIdx } = args
287 if (mode !== 'SELECT') return
288 const key = event.key.toLowerCase()
289
290 if (key === 'c' && (event.metaKey || event.ctrlKey)) {
291 if (window.getSelection()?.isCollapsed === false) return
292
293 const value = formatClipboardValue(row[column.key] ?? '')
294 event.preventDefault()
295 event.preventGridDefault()
296 void copyToClipboard(value, () => {
297 toast.success('Copied cell value to clipboard')
298 })
299 return
300 }
301
302 // Let registered shortcuts win over rdg's "type a key to enter edit mode" default,
303 // unless a printable key enters edit mode.
304 if (eventMatchesAnyShortcut(event.nativeEvent, tableEditorRegistry)) {
305 if (
306 event.key.length === 1 &&
307 event.key !== ' ' &&
308 !event.altKey &&
309 !event.ctrlKey &&
310 !event.metaKey &&
311 !event.shiftKey &&
312 column.renderEditCell != null
313 ) {
314 event.stopPropagation()
315 } else {
316 event.preventGridDefault()
317 return
318 }
319 }
320
321 // Toggle boolean cells with T/F when no modifier keys are pressed.
322 if (context === undefined) return
323
324 if (event.altKey || event.ctrlKey || event.metaKey || (key !== 't' && key !== 'f')) return
325
326 const supaColumn = context.columns.find((c) => c.name === column.key)
327 if (
328 supaColumn === undefined ||
329 !isBoolColumn(supaColumn.dataType) ||
330 column.renderEditCell == null
331 ) {
332 return
333 }
334
335 event.preventDefault()
336 event.preventGridDefault()
337
338 const nextValue = key === 't'
339 if (row[column.key] === nextValue) return
340
341 const updatedRows = [...context.rows]
342 updatedRows[rowIdx] = { ...row, [column.key]: nextValue }
343 context.onRowsChange(updatedRows, { indexes: [rowIdx], column })
344}