table-editor.tsx381 lines · main
1import * as Sentry from '@sentry/nextjs'
2import type { PGColumn } from '@supabase/pg-meta'
3import { useConstant } from 'common'
4import { createContext, PropsWithChildren, useContext } from 'react'
5import { proxy, useSnapshot } from 'valtio'
6
7import {
8 NewQueuedOperation,
9 QueuedOperationType,
10 type OperationQueueState,
11 type QueueStatus,
12} from './table-editor-operation-queue.types'
13import type { SupaRow } from '@/components/grid/types'
14import {
15 resolveDeleteRowConflicts,
16 resolveEditCellConflicts,
17 upsertOperation,
18} from '@/components/grid/utils/queueConflictResolution'
19import { generateTableChangeKey } from '@/components/grid/utils/queueOperationUtils'
20import { ForeignKey } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types'
21import type { EditValue } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types'
22import type { TableField } from '@/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types'
23import type { SafePostgresColumn } from '@/lib/postgres-types'
24import type { Dictionary } from '@/types'
25
26export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100
27
28type ForeignKeyState = {
29 foreignKey: ForeignKey
30 row: Dictionary<any>
31 column: PGColumn
32}
33
34export type SidePanel =
35 | { type: 'cell'; value?: { column: string; row: Dictionary<any> } }
36 | { type: 'row'; row?: Dictionary<any> }
37 | { type: 'column'; column?: SafePostgresColumn }
38 | { type: 'table'; mode: 'new' | 'edit' | 'duplicate'; templateData?: Partial<TableField> }
39 | { type: 'schema'; mode: 'new' | 'edit' }
40 | { type: 'json'; jsonValue: EditValue }
41 | {
42 type: 'foreign-row-selector'
43 foreignKey: ForeignKeyState
44 }
45 | { type: 'csv-import'; file?: File }
46 | { type: 'operation-queue' }
47
48export type ConfirmationDialog =
49 | { type: 'table'; isDeleteWithCascade: boolean }
50 | { type: 'column'; column: SafePostgresColumn; isDeleteWithCascade: boolean }
51 // [Joshen] Just FYI callback, numRows, allRowsSelected is a temp workaround so that
52 // DeleteConfirmationDialog can trigger dispatch methods after the successful deletion of rows.
53 // Once we deprecate react tracked and move things to valtio, we can remove this.
54 | {
55 type: 'row'
56 rows: SupaRow[]
57 numRows?: number
58 allRowsSelected?: boolean
59 callback?: () => void
60 }
61
62export type UIState =
63 | {
64 open: 'none'
65 }
66 | {
67 open: 'side-panel'
68 sidePanel: SidePanel
69 }
70 | {
71 open: 'confirmation-dialog'
72 confirmationDialog: ConfirmationDialog
73 }
74
75/**
76 * Global table editor state for the table editor across multiple tables.
77 * See ./table-editor-table.tsx for table specific state.
78 */
79export const createTableEditorState = () => {
80 const state = proxy({
81 rowsPerPage: TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE,
82 setRowsPerPage: (rowsPerPage: number) => {
83 state.rowsPerPage = rowsPerPage
84 },
85
86 ui: { open: 'none' } as UIState,
87 get sidePanel() {
88 return state.ui.open === 'side-panel' ? state.ui.sidePanel : undefined
89 },
90 get confirmationDialog() {
91 return state.ui.open === 'confirmation-dialog' ? state.ui.confirmationDialog : undefined
92 },
93
94 closeSidePanel: () => {
95 state.ui = { open: 'none' }
96 },
97 closeConfirmationDialog: () => {
98 state.ui = { open: 'none' }
99 },
100
101 onAddSchema: () => {
102 state.ui = {
103 open: 'side-panel',
104 sidePanel: { type: 'schema', mode: 'new' },
105 }
106 },
107
108 /* Tables */
109 onAddTable: (templateData?: Partial<TableField>) => {
110 // Record that the table creator was opened
111 Sentry.startSpan({ name: 'table_creator.opened', op: 'ui.action' }, (span) => {
112 span.setAttribute('table_creator.opened', 1)
113 })
114
115 state.ui = {
116 open: 'side-panel',
117 sidePanel: { type: 'table', mode: 'new', templateData },
118 }
119 },
120 onEditTable: () => {
121 state.ui = {
122 open: 'side-panel',
123 sidePanel: { type: 'table', mode: 'edit' },
124 }
125 },
126 onDuplicateTable: () => {
127 state.ui = {
128 open: 'side-panel',
129 sidePanel: { type: 'table', mode: 'duplicate' },
130 }
131 },
132 onDeleteTable: () => {
133 state.ui = {
134 open: 'confirmation-dialog',
135 confirmationDialog: { type: 'table', isDeleteWithCascade: false },
136 }
137 },
138
139 /* Columns */
140 onAddColumn: () => {
141 state.ui = {
142 open: 'side-panel',
143 sidePanel: { type: 'column' },
144 }
145 },
146 onEditColumn: (column: SafePostgresColumn) => {
147 state.ui = {
148 open: 'side-panel',
149 sidePanel: { type: 'column', column },
150 }
151 },
152 onDeleteColumn: (column: SafePostgresColumn) => {
153 state.ui = {
154 open: 'confirmation-dialog',
155 confirmationDialog: { type: 'column', column, isDeleteWithCascade: false },
156 }
157 },
158
159 /* Rows */
160 onAddRow: () => {
161 state.ui = {
162 open: 'side-panel',
163 sidePanel: { type: 'row' },
164 }
165 },
166 onEditRow: (row: Dictionary<any>) => {
167 state.ui = {
168 open: 'side-panel',
169 sidePanel: { type: 'row', row },
170 }
171 },
172 onDeleteRows: (
173 rows: SupaRow[],
174 meta: { numRows?: number; allRowsSelected: boolean; callback?: () => void } = {
175 numRows: 0,
176 allRowsSelected: false,
177 callback: () => {},
178 }
179 ) => {
180 const { numRows, allRowsSelected, callback } = meta
181 state.ui = {
182 open: 'confirmation-dialog',
183 confirmationDialog: { type: 'row', rows, numRows, allRowsSelected, callback },
184 }
185 },
186
187 /* Misc */
188 onExpandJSONEditor: (jsonValue: EditValue) => {
189 state.ui = {
190 open: 'side-panel',
191 sidePanel: { type: 'json', jsonValue },
192 }
193 },
194 onExpandTextEditor: (column: string, row: Dictionary<any>) => {
195 state.ui = {
196 open: 'side-panel',
197 sidePanel: { type: 'cell', value: { column, row } },
198 }
199 },
200 onEditForeignKeyColumnValue: (foreignKey: ForeignKeyState) => {
201 state.ui = {
202 open: 'side-panel',
203 sidePanel: { type: 'foreign-row-selector', foreignKey },
204 }
205 },
206 onImportData: (file?: File) => {
207 state.ui = {
208 open: 'side-panel',
209 sidePanel: { type: 'csv-import', file },
210 }
211 },
212 toggleViewOperationQueue: () => {
213 if (state.ui.open === 'side-panel' && state.ui.sidePanel.type === 'operation-queue') {
214 state.closeSidePanel()
215 } else {
216 state.ui = {
217 open: 'side-panel',
218 sidePanel: { type: 'operation-queue' },
219 }
220 }
221 },
222
223 /* Utils */
224 toggleConfirmationIsWithCascade: (overrideIsDeleteWithCascade?: boolean) => {
225 if (
226 state.ui.open === 'confirmation-dialog' &&
227 (state.ui.confirmationDialog.type === 'column' ||
228 state.ui.confirmationDialog.type === 'table')
229 ) {
230 state.ui.confirmationDialog.isDeleteWithCascade =
231 overrideIsDeleteWithCascade ?? !state.ui.confirmationDialog.isDeleteWithCascade
232 }
233 },
234
235 // ========================================================================
236 // Operation Queue
237 // ========================================================================
238
239 operationQueue: {
240 operations: [],
241 status: 'idle',
242 } as OperationQueueState,
243
244 /**
245 * Queue a new operation for later processing.
246 * If an operation with the same key already exists, it will be overwritten.
247 * Handles conflict resolution:
248 * - DELETE_ROW on a row: remove any pending EDIT_CELL ops for that row
249 * - EDIT_CELL on a row pending deletion: reject (console.warn)
250 * - EDIT_CELL on a newly added row: merge edit into ADD_ROW's rowData
251 * - DELETE_ROW on a newly added row: cancel both operations
252 */
253 queueOperation: (operation: NewQueuedOperation) => {
254 const updateQueueStatus = () => {
255 if (state.operationQueue.operations.length === 0) {
256 state.operationQueue.status = 'idle'
257 } else if (state.operationQueue.status === 'idle') {
258 state.operationQueue.status = 'pending'
259 }
260 }
261
262 // Handle DELETE_ROW conflicts
263 if (operation.type === QueuedOperationType.DELETE_ROW) {
264 const result = resolveDeleteRowConflicts(state.operationQueue.operations, operation)
265 state.operationQueue.operations = result.filteredOperations
266 if (result.action === 'skip') {
267 updateQueueStatus()
268 return
269 }
270 }
271
272 // Handle EDIT_CELL_CONTENT conflicts
273 if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
274 const result = resolveEditCellConflicts(state.operationQueue.operations, operation)
275 if (result.action === 'reject') {
276 console.warn(result.reason)
277 return
278 }
279 if (result.action === 'merge') {
280 state.operationQueue.operations = result.updatedOperations
281 updateQueueStatus()
282 return
283 }
284 }
285
286 // Normal upsert
287 const { operations } = upsertOperation(state.operationQueue.operations, operation)
288 state.operationQueue.operations = operations
289 updateQueueStatus()
290 },
291
292 /**
293 * Clear all operations from the queue
294 */
295 clearQueue: () => {
296 state.operationQueue.operations = []
297 state.operationQueue.status = 'idle'
298 },
299
300 /**
301 * Remove a specific operation from the queue
302 */
303 removeOperation: (operationId: string) => {
304 state.operationQueue.operations = state.operationQueue.operations.filter(
305 (op) => op.id !== operationId
306 )
307 if (state.operationQueue.operations.length === 0) {
308 state.operationQueue.status = 'idle'
309 }
310 },
311
312 /**
313 * Undo the latest operation from the queue
314 */
315 undoLatestOperation: () => {
316 state.operationQueue.operations = state.operationQueue.operations.slice(0, -1)
317 if (state.operationQueue.operations.length === 0) {
318 state.operationQueue.status = 'idle'
319 }
320 },
321
322 /**
323 * Update the queue status
324 */
325 setQueueStatus: (status: QueueStatus) => {
326 state.operationQueue.status = status
327 },
328
329 /**
330 * Check if there are any pending operations in the queue
331 */
332 get hasPendingOperations(): boolean {
333 return state.operationQueue.operations.length > 0
334 },
335
336 hasPendingCellChange: (
337 tableId: number,
338 rowIdentifiers: Dictionary<unknown>,
339 columnName: string
340 ): boolean => {
341 const key = generateTableChangeKey({
342 type: QueuedOperationType.EDIT_CELL_CONTENT,
343 tableId,
344 payload: {
345 columnName,
346 rowIdentifiers,
347 },
348 })
349 return state.operationQueue.operations.some((op) => op.id === key)
350 },
351
352 /**
353 * Toggle the preflight check behaviour for each table
354 */
355 tablesToIgnorePreflightCheck: [] as number[],
356 setTableToIgnorePreflightCheck: (id: number) => {
357 const set = new Set<number>(state.tablesToIgnorePreflightCheck)
358 set.add(id)
359 state.tablesToIgnorePreflightCheck = [...set]
360 },
361 })
362
363 return state
364}
365
366export type TableEditorState = ReturnType<typeof createTableEditorState>
367
368export const TableEditorStateContext = createContext<TableEditorState>(createTableEditorState())
369
370export const TableEditorStateContextProvider = ({ children }: PropsWithChildren<{}>) => {
371 const state = useConstant(createTableEditorState)
372
373 return (
374 <TableEditorStateContext.Provider value={state}>{children}</TableEditorStateContext.Provider>
375 )
376}
377
378export const useTableEditorStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
379 const state = useContext(TableEditorStateContext)
380 return useSnapshot(state, options)
381}