BrivenGrid.utils.ts 11 KB

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