table-editor-table.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import { createContext, PropsWithChildren, useContext, useEffect, useRef } from 'react'
  2. import { CalculatedColumn } from 'react-data-grid'
  3. import { proxy, ref, subscribe, useSnapshot } from 'valtio'
  4. import { proxySet } from 'valtio/utils'
  5. import { useTableEditorStateSnapshot } from './table-editor'
  6. import { TableIndexAdvisorProvider } from '@/components/grid/context/TableIndexAdvisorContext'
  7. import {
  8. loadTableEditorStateFromLocalStorage,
  9. parseSupaTable,
  10. saveTableEditorStateToLocalStorageDebounced,
  11. } from '@/components/grid/BrivenGrid.utils'
  12. import { Filter, SupaRow } from '@/components/grid/types'
  13. import { getInitialGridColumns } from '@/components/grid/utils/column'
  14. import { getGridColumns } from '@/components/grid/utils/gridColumns'
  15. import { Entity } from '@/data/table-editor/table-editor-types'
  16. const FALLBACK_TABLE_STATE = proxy({}) as TableEditorTableState
  17. export const createTableEditorTableState = ({
  18. projectRef,
  19. table: originalTable,
  20. editable = true,
  21. preflightCheck = true,
  22. onAddColumn,
  23. onExpandJSONEditor,
  24. onExpandTextEditor,
  25. }: {
  26. projectRef: string
  27. table: Entity
  28. /** If set to true, render an additional "+" column to support adding a new column in the grid editor */
  29. editable?: boolean
  30. preflightCheck?: boolean
  31. onAddColumn: () => void
  32. onExpandJSONEditor: (column: string, row: SupaRow) => void
  33. onExpandTextEditor: (column: string, row: SupaRow) => void
  34. }) => {
  35. const table = parseSupaTable(originalTable)
  36. const savedState = loadTableEditorStateFromLocalStorage(projectRef, table.id)
  37. const gridColumns = getInitialGridColumns(
  38. getGridColumns(table, {
  39. tableId: table.id,
  40. editable,
  41. onAddColumn: editable ? onAddColumn : undefined,
  42. onExpandJSONEditor,
  43. onExpandTextEditor,
  44. }),
  45. savedState
  46. )
  47. const state = proxy({
  48. /* Table */
  49. table,
  50. originalTable,
  51. /**
  52. * Used for tracking changes to the table
  53. * Do not use outside of table-editor-table.tsx
  54. */
  55. _originalTableRef: ref(originalTable),
  56. updateTable: (table: Entity) => {
  57. const supaTable = parseSupaTable(table)
  58. const gridColumns = getInitialGridColumns(
  59. getGridColumns(supaTable, {
  60. tableId: table.id,
  61. editable: state.editable,
  62. onAddColumn: state.editable ? onAddColumn : undefined,
  63. onExpandJSONEditor,
  64. onExpandTextEditor,
  65. }),
  66. { gridColumns: state.gridColumns }
  67. )
  68. state.table = supaTable
  69. state.gridColumns = gridColumns
  70. state.originalTable = table
  71. state._originalTableRef = ref(table)
  72. },
  73. /* Rows */
  74. selectedRows: proxySet<number>(),
  75. allRowsSelected: false,
  76. setSelectedRows: (rows: Set<number>, selectAll?: boolean) => {
  77. state.allRowsSelected = selectAll ?? false
  78. state.selectedRows = proxySet(rows)
  79. },
  80. resetSelectedRows: () => {
  81. state.allRowsSelected = false
  82. state.selectedRows = proxySet(new Set())
  83. },
  84. /* Columns */
  85. gridColumns,
  86. moveColumn: (fromKey: string, toKey: string) => {
  87. const fromIdx = state.gridColumns.findIndex((x) => x.key === fromKey)
  88. const toIdx = state.gridColumns.findIndex((x) => x.key === toKey)
  89. if (fromIdx === -1 || toIdx === -1) return
  90. const moveItem = state.gridColumns[fromIdx]
  91. const overItem = state.gridColumns[toIdx]
  92. if (moveItem.frozen || overItem.frozen) return
  93. state.gridColumns.splice(fromIdx, 1)
  94. state.gridColumns.splice(toIdx, 0, moveItem)
  95. // Update idx values to match new positions
  96. state.gridColumns.forEach((col, i) => {
  97. ;(col as CalculatedColumn<any, any> & { idx: number }).idx = i
  98. })
  99. },
  100. updateColumnSize: (index: number, width: number) => {
  101. if (state.gridColumns[index]) {
  102. ;(state.gridColumns[index] as CalculatedColumn<any, any> & { width?: number }).width = width
  103. }
  104. },
  105. freezeColumn: (columnKey: string) => {
  106. const index = state.gridColumns.findIndex((x) => x.key === columnKey)
  107. if (index === -1) return
  108. ;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen = true
  109. // Move the column to just after the last currently-frozen column
  110. const lastFrozenIdx = state.gridColumns.reduce(
  111. (last, col, i) => (col.frozen && i !== index ? i : last),
  112. -1
  113. )
  114. const col = state.gridColumns[index]
  115. state.gridColumns.splice(index, 1)
  116. state.gridColumns.splice(lastFrozenIdx + 1, 0, col)
  117. state.gridColumns.forEach((col, i) => {
  118. ;(col as CalculatedColumn<any, any> & { idx: number }).idx = i
  119. })
  120. },
  121. unfreezeColumn: (columnKey: string) => {
  122. const index = state.gridColumns.findIndex((x) => x.key === columnKey)
  123. if (index === -1) return
  124. ;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen =
  125. false
  126. // Move the column to just after the remaining frozen columns
  127. const col = state.gridColumns[index]
  128. state.gridColumns.splice(index, 1)
  129. const lastFrozenIdx = state.gridColumns.reduce((last, col, i) => (col.frozen ? i : last), -1)
  130. state.gridColumns.splice(lastFrozenIdx + 1, 0, col)
  131. state.gridColumns.forEach((col, i) => {
  132. ;(col as CalculatedColumn<any, any> & { idx: number }).idx = i
  133. })
  134. },
  135. updateColumnIdx: (columnKey: string, columnIdx: number) => {
  136. const index = state.gridColumns.findIndex((x) => x.key === columnKey)
  137. if (state.gridColumns[index]) {
  138. ;(state.gridColumns[index] as CalculatedColumn<any, any> & { idx?: number }).idx = columnIdx
  139. }
  140. state.gridColumns.sort((a, b) => a.idx - b.idx)
  141. },
  142. /* Cells */
  143. selectedCellPosition: null as { idx: number; rowIdx: number } | null,
  144. setSelectedCellPosition: (position: { idx: number; rowIdx: number } | null) => {
  145. state.selectedCellPosition = position
  146. },
  147. /* Misc */
  148. enforceExactCount: false,
  149. setEnforceExactCount: (value: boolean) => {
  150. state.enforceExactCount = value
  151. },
  152. page: 1,
  153. setPage: (page: number) => {
  154. state.page = page
  155. // reset selected row state
  156. state.setSelectedRows(new Set())
  157. },
  158. editable,
  159. setEditable: (editable: boolean) => {
  160. state.editable = editable
  161. // When changing the editable flag, all grid columns need to be recreated for the editable flag to be propagated.
  162. state.gridColumns = getInitialGridColumns(
  163. getGridColumns(state.table, {
  164. tableId: table.id,
  165. editable,
  166. onAddColumn: editable ? onAddColumn : undefined,
  167. onExpandJSONEditor,
  168. onExpandTextEditor,
  169. }),
  170. { gridColumns: state.gridColumns }
  171. )
  172. },
  173. /* Filters (NOTE: this is only for the new AI filter bar) */
  174. filters: [] as Filter[],
  175. setFilters: (filters: Filter[]) => {
  176. state.filters = filters
  177. },
  178. clearFilters: () => {
  179. state.filters = []
  180. },
  181. preflightCheck,
  182. setPreflightCheck: (value: boolean) => (state.preflightCheck = value),
  183. })
  184. return state
  185. }
  186. export type TableEditorTableState = ReturnType<typeof createTableEditorTableState>
  187. export const TableEditorTableStateContext = createContext<TableEditorTableState>(undefined as any)
  188. type TableEditorTableStateContextProviderProps = Omit<
  189. Parameters<typeof createTableEditorTableState>[0],
  190. 'onAddColumn' | 'onExpandJSONEditor' | 'onExpandTextEditor'
  191. >
  192. export const TableEditorTableStateContextProvider = ({
  193. children,
  194. projectRef,
  195. table,
  196. ...props
  197. }: PropsWithChildren<TableEditorTableStateContextProviderProps>) => {
  198. const tableEditorSnap = useTableEditorStateSnapshot()
  199. const state = useRef(
  200. createTableEditorTableState({
  201. ...props,
  202. projectRef,
  203. table,
  204. onAddColumn: tableEditorSnap.onAddColumn,
  205. onExpandJSONEditor: (column: string, row: SupaRow) => {
  206. tableEditorSnap.onExpandJSONEditor({
  207. column,
  208. row,
  209. value: JSON.stringify(row[column]) || '',
  210. })
  211. },
  212. onExpandTextEditor: (column: string, row: SupaRow) => {
  213. tableEditorSnap.onExpandTextEditor(column, row)
  214. },
  215. })
  216. ).current
  217. useEffect(() => {
  218. if (typeof window !== 'undefined') {
  219. return subscribe(state, () => {
  220. saveTableEditorStateToLocalStorageDebounced({
  221. gridColumns: state.gridColumns,
  222. projectRef,
  223. tableId: state.table.id,
  224. })
  225. })
  226. }
  227. // eslint-disable-next-line react-hooks/exhaustive-deps
  228. }, [])
  229. useEffect(() => {
  230. // We can use a === check here because react-query is good
  231. // about returning objects with the same ref / different ref
  232. if (state._originalTableRef !== table) {
  233. state.updateTable(table)
  234. }
  235. }, [table])
  236. useEffect(() => {
  237. if (state.editable !== props.editable) {
  238. state.setEditable(props.editable ?? true)
  239. }
  240. }, [props.editable, state])
  241. return (
  242. <TableEditorTableStateContext.Provider value={state}>
  243. {state.table.schema ? (
  244. <TableIndexAdvisorProvider schema={state.table.schema ?? 'public'} table={state.table.name}>
  245. {children}
  246. </TableIndexAdvisorProvider>
  247. ) : (
  248. children
  249. )}
  250. </TableEditorTableStateContext.Provider>
  251. )
  252. }
  253. export const useTableEditorTableStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
  254. const state = useContext(TableEditorTableStateContext)
  255. // as TableEditorTableState so this doesn't get marked as readonly,
  256. // making adopting this state easier since we're migrating from react-tracked
  257. return useSnapshot(state, options) as TableEditorTableState
  258. }
  259. /**
  260. * Same as useTableEditorTableStateSnapshot but returns undefined when called
  261. * outside a TableEditorTableStateContextProvider instead of crashing.
  262. * Use this for components that may render outside the provider (e.g. sidebar).
  263. */
  264. export const useOptionalTableEditorTableStateSnapshot = (): TableEditorTableState | undefined => {
  265. const state = useContext(TableEditorTableStateContext)
  266. const snap = useSnapshot(state ?? FALLBACK_TABLE_STATE)
  267. return state != undefined ? (snap as TableEditorTableState) : undefined
  268. }
  269. export type TableEditorTableStateSnapshot = ReturnType<typeof useTableEditorTableStateSnapshot>