table-editor.tsx381 lines · main
| 1 | import * as Sentry from '@sentry/nextjs' |
| 2 | import type { PGColumn } from '@supabase/pg-meta' |
| 3 | import { useConstant } from 'common' |
| 4 | import { createContext, PropsWithChildren, useContext } from 'react' |
| 5 | import { proxy, useSnapshot } from 'valtio' |
| 6 | |
| 7 | import { |
| 8 | NewQueuedOperation, |
| 9 | QueuedOperationType, |
| 10 | type OperationQueueState, |
| 11 | type QueueStatus, |
| 12 | } from './table-editor-operation-queue.types' |
| 13 | import type { SupaRow } from '@/components/grid/types' |
| 14 | import { |
| 15 | resolveDeleteRowConflicts, |
| 16 | resolveEditCellConflicts, |
| 17 | upsertOperation, |
| 18 | } from '@/components/grid/utils/queueConflictResolution' |
| 19 | import { generateTableChangeKey } from '@/components/grid/utils/queueOperationUtils' |
| 20 | import { ForeignKey } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types' |
| 21 | import type { EditValue } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types' |
| 22 | import type { TableField } from '@/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types' |
| 23 | import type { SafePostgresColumn } from '@/lib/postgres-types' |
| 24 | import type { Dictionary } from '@/types' |
| 25 | |
| 26 | export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100 |
| 27 | |
| 28 | type ForeignKeyState = { |
| 29 | foreignKey: ForeignKey |
| 30 | row: Dictionary<any> |
| 31 | column: PGColumn |
| 32 | } |
| 33 | |
| 34 | export 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 | |
| 48 | export 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 | |
| 62 | export 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 | */ |
| 79 | export 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 | |
| 366 | export type TableEditorState = ReturnType<typeof createTableEditorState> |
| 367 | |
| 368 | export const TableEditorStateContext = createContext<TableEditorState>(createTableEditorState()) |
| 369 | |
| 370 | export const TableEditorStateContextProvider = ({ children }: PropsWithChildren<{}>) => { |
| 371 | const state = useConstant(createTableEditorState) |
| 372 | |
| 373 | return ( |
| 374 | <TableEditorStateContext.Provider value={state}>{children}</TableEditorStateContext.Provider> |
| 375 | ) |
| 376 | } |
| 377 | |
| 378 | export const useTableEditorStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => { |
| 379 | const state = useContext(TableEditorStateContext) |
| 380 | return useSnapshot(state, options) |
| 381 | } |