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