SidePanelEditor.tsx1020 lines · main
| 1 | import * as Sentry from '@sentry/nextjs' |
| 2 | import type { PGTable } from '@supabase/pg-meta' |
| 3 | import { useQueryClient } from '@tanstack/react-query' |
| 4 | import { useParams } from 'common' |
| 5 | import { isEmpty, isUndefined, noop } from 'lodash' |
| 6 | import { useState } from 'react' |
| 7 | import { toast } from 'sonner' |
| 8 | import { SonnerProgress } from 'ui' |
| 9 | |
| 10 | import { ColumnEditor } from './ColumnEditor/ColumnEditor' |
| 11 | import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types' |
| 12 | import { OperationQueueSidePanel } from './OperationQueueSidePanel/OperationQueueSidePanel' |
| 13 | import { ForeignRowSelector } from './RowEditor/ForeignRowSelector/ForeignRowSelector' |
| 14 | import { JsonEditor } from './RowEditor/JsonEditor' |
| 15 | import { RowEditor } from './RowEditor/RowEditor' |
| 16 | import { convertByteaToHex } from './RowEditor/RowEditor.utils' |
| 17 | import { TextEditor } from './RowEditor/TextEditor' |
| 18 | import { SchemaEditor } from './SchemaEditor' |
| 19 | import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types' |
| 20 | import { |
| 21 | createColumn, |
| 22 | createTable, |
| 23 | duplicateTable, |
| 24 | getRowFromSidePanel, |
| 25 | insertRowsViaSpreadsheet, |
| 26 | insertTableRows, |
| 27 | updateColumn, |
| 28 | updateTable, |
| 29 | } from './SidePanelEditor.utils' |
| 30 | import { SpreadsheetImport } from './SpreadsheetImport/SpreadsheetImport' |
| 31 | import { |
| 32 | useTableApiAccessHandlerWithHistory, |
| 33 | type TableApiAccessParams, |
| 34 | } from './TableEditor/ApiAccessToggle' |
| 35 | import { TableEditor } from './TableEditor/TableEditor' |
| 36 | import type { ImportContent } from './TableEditor/TableEditor.types' |
| 37 | import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations' |
| 38 | import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' |
| 39 | import { |
| 40 | acceptGeneratedPolicy, |
| 41 | type GeneratedPolicy, |
| 42 | } from '@/components/interfaces/Auth/Policies/Policies.utils' |
| 43 | import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' |
| 44 | import { databasePoliciesKeys } from '@/data/database-policies/keys' |
| 45 | import { useDatabasePublicationCreateMutation } from '@/data/database-publications/database-publications-create-mutation' |
| 46 | import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query' |
| 47 | import { useDatabasePublicationUpdateMutation } from '@/data/database-publications/database-publications-update-mutation' |
| 48 | import type { Constraint } from '@/data/database/constraints-query' |
| 49 | import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' |
| 50 | import { databaseKeys } from '@/data/database/keys' |
| 51 | import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' |
| 52 | import { entityTypeKeys } from '@/data/entity-types/keys' |
| 53 | import { lintKeys } from '@/data/lint/keys' |
| 54 | import { privilegeKeys } from '@/data/privileges/keys' |
| 55 | import { useTableApiAccessPrivilegesMutation } from '@/data/privileges/table-api-access-mutation' |
| 56 | import { tableEditorKeys } from '@/data/table-editor/keys' |
| 57 | import { isTableLike, type Entity } from '@/data/table-editor/table-editor-types' |
| 58 | import { tableRowKeys } from '@/data/table-rows/keys' |
| 59 | import { tableKeys } from '@/data/tables/keys' |
| 60 | import { RetrieveTableResult } from '@/data/tables/table-retrieve-query' |
| 61 | import { getTables } from '@/data/tables/tables-query' |
| 62 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 63 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 64 | import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' |
| 65 | import { useUrlState } from '@/hooks/ui/useUrlState' |
| 66 | import { useVisibleKey } from '@/hooks/ui/useVisibleKey' |
| 67 | import { type ApiPrivilegesByRole } from '@/lib/data-api-types' |
| 68 | import { isObjectContainingKeys } from '@/lib/helpers' |
| 69 | import type { SafePostgresTable } from '@/lib/postgres-types' |
| 70 | import { useTrack } from '@/lib/telemetry/track' |
| 71 | import type { DeepReadonly, Prettify } from '@/lib/type-helpers' |
| 72 | import { useTableEditorStateSnapshot, type TableEditorState } from '@/state/table-editor' |
| 73 | import { createTabId, useTabsStateSnapshot } from '@/state/tabs' |
| 74 | import type { Dictionary } from '@/types' |
| 75 | |
| 76 | export type SaveTableParams = |
| 77 | | SaveTableParamsNew |
| 78 | | SaveTableParamsDuplicate |
| 79 | | SaveTableParamsExisting |
| 80 | |
| 81 | type SaveTableParamsBase = { |
| 82 | configuration: SaveTableConfiguration |
| 83 | columns: ColumnField[] |
| 84 | foreignKeyRelations: ForeignKey[] |
| 85 | resolve: () => void |
| 86 | generatedPolicies?: GeneratedPolicy[] |
| 87 | } |
| 88 | |
| 89 | type SaveTableParamsNew = SaveTableParamsBase & { |
| 90 | action: 'create' |
| 91 | payload: SaveTablePayloadNew |
| 92 | } |
| 93 | |
| 94 | type SaveTableParamsDuplicate = SaveTableParamsBase & { |
| 95 | action: 'duplicate' |
| 96 | payload: SaveTablePayloadDuplicate |
| 97 | } |
| 98 | |
| 99 | type SaveTableParamsExisting = SaveTableParamsBase & { |
| 100 | action: 'update' |
| 101 | payload: SaveTablePayloadExisting |
| 102 | } |
| 103 | |
| 104 | type SaveTablePayloadBase = { |
| 105 | /** |
| 106 | * Comment to set on the table |
| 107 | * |
| 108 | * `null` removes existing comment |
| 109 | * `undefined` leaves comment unchanged |
| 110 | */ |
| 111 | comment?: string | null |
| 112 | } |
| 113 | |
| 114 | type SaveTablePayloadNew = SaveTablePayloadBase & { |
| 115 | name: string |
| 116 | schema: string |
| 117 | } |
| 118 | |
| 119 | type SaveTablePayloadDuplicate = SaveTablePayloadBase & { |
| 120 | name: string |
| 121 | } |
| 122 | |
| 123 | type SaveTablePayloadExisting = SaveTablePayloadBase & { |
| 124 | name?: string |
| 125 | rls_enabled?: boolean |
| 126 | } |
| 127 | |
| 128 | type SaveTableConfiguration = Prettify<{ |
| 129 | tableId?: number |
| 130 | importContent?: ImportContent |
| 131 | isRLSEnabled: boolean |
| 132 | isRealtimeEnabled: boolean |
| 133 | isDuplicateRows: boolean |
| 134 | existingForeignKeyRelations: ForeignKeyConstraint[] |
| 135 | primaryKey?: Constraint |
| 136 | }> |
| 137 | |
| 138 | const DUMMY_TABLE_API_ACCESS_PARAMS: TableApiAccessParams = { |
| 139 | type: 'new', |
| 140 | } |
| 141 | |
| 142 | const createTableApiAccessHandlerParams = ({ |
| 143 | snap, |
| 144 | selectedTable, |
| 145 | }: { |
| 146 | snap: DeepReadonly<TableEditorState> |
| 147 | selectedTable?: PGTable |
| 148 | }): TableApiAccessParams | undefined => { |
| 149 | const tableSidePanel = snap.sidePanel?.type === 'table' ? snap.sidePanel : undefined |
| 150 | if (!tableSidePanel) return undefined |
| 151 | |
| 152 | if (tableSidePanel.mode === 'new') { |
| 153 | return { |
| 154 | type: 'new', |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | if (!selectedTable) return undefined |
| 159 | |
| 160 | if (tableSidePanel.mode === 'duplicate') { |
| 161 | return { |
| 162 | type: 'duplicate', |
| 163 | templateSchemaName: selectedTable.schema, |
| 164 | templateTableName: selectedTable.name, |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | return { |
| 169 | type: 'edit', |
| 170 | schemaName: selectedTable.schema, |
| 171 | tableName: selectedTable.name, |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | export interface SidePanelEditorProps { |
| 176 | editable?: boolean |
| 177 | selectedTable?: SafePostgresTable |
| 178 | includeColumns?: boolean // This is mainly used for invalidating useTablesQuery |
| 179 | |
| 180 | // Because the panel is shared between grid editor and database pages |
| 181 | // Both require different responses upon success of these events |
| 182 | onTableCreated?: (table: RetrieveTableResult) => void |
| 183 | } |
| 184 | |
| 185 | export const SidePanelEditor = ({ |
| 186 | editable = true, |
| 187 | selectedTable, |
| 188 | includeColumns = false, |
| 189 | onTableCreated = noop, |
| 190 | }: SidePanelEditorProps) => { |
| 191 | const { ref } = useParams() |
| 192 | const snap = useTableEditorStateSnapshot() |
| 193 | const tabsSnap = useTabsStateSnapshot() |
| 194 | const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) |
| 195 | |
| 196 | const track = useTrack() |
| 197 | const queryClient = useQueryClient() |
| 198 | const { data: project } = useSelectedProjectQuery() |
| 199 | const { data: org } = useSelectedOrganizationQuery() |
| 200 | const isQueueOperationsEnabled = useIsQueueOperationsEnabled() |
| 201 | const { updateRow, addRow, isEditPending } = useTableRowOperations() |
| 202 | |
| 203 | const [isEdited, setIsEdited] = useState<boolean>(false) |
| 204 | const csvImportKey = useVisibleKey(snap.sidePanel?.type === 'csv-import') |
| 205 | |
| 206 | const { data: publications } = useDatabasePublicationsQuery({ |
| 207 | projectRef: project?.ref, |
| 208 | connectionString: project?.connectionString, |
| 209 | }) |
| 210 | |
| 211 | const tableApiAccessParams = createTableApiAccessHandlerParams({ |
| 212 | snap, |
| 213 | selectedTable, |
| 214 | }) |
| 215 | const apiAccessToggleHandler = useTableApiAccessHandlerWithHistory( |
| 216 | // Dummy params used to appease TypeScript, actually gated by enabled flag |
| 217 | tableApiAccessParams ?? DUMMY_TABLE_API_ACCESS_PARAMS, |
| 218 | { |
| 219 | enabled: tableApiAccessParams !== undefined, |
| 220 | } |
| 221 | ) |
| 222 | |
| 223 | const { confirmOnClose, modalProps } = useConfirmOnClose({ |
| 224 | checkIsDirty: () => isEdited, |
| 225 | onClose: () => { |
| 226 | setIsEdited(false) |
| 227 | snap.closeSidePanel() |
| 228 | }, |
| 229 | }) |
| 230 | |
| 231 | const enumArrayColumns = (selectedTable?.columns ?? []) |
| 232 | .filter((column) => { |
| 233 | return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array' |
| 234 | }) |
| 235 | .map((column) => column.name) |
| 236 | |
| 237 | const { mutateAsync: createPublication } = useDatabasePublicationCreateMutation() |
| 238 | const { mutateAsync: updatePublication } = useDatabasePublicationUpdateMutation({ |
| 239 | onError: () => {}, |
| 240 | }) |
| 241 | const { mutateAsync: updateApiPrivileges } = useTableApiAccessPrivilegesMutation({ |
| 242 | onError: () => {}, // Errors handled inline |
| 243 | }) |
| 244 | |
| 245 | const isDuplicating = snap.sidePanel?.type === 'table' && snap.sidePanel.mode === 'duplicate' |
| 246 | |
| 247 | const saveRow = async ( |
| 248 | payload: any, |
| 249 | isNewRecord: boolean, |
| 250 | configuration: { identifiers: any; rowIdx: number; createMore?: boolean }, |
| 251 | onComplete: (err?: any) => void |
| 252 | ) => { |
| 253 | if (!project || selectedTable === undefined) { |
| 254 | return console.error('no project or table selected') |
| 255 | } |
| 256 | |
| 257 | let saveRowError: Error | undefined |
| 258 | if (isNewRecord) { |
| 259 | try { |
| 260 | await addRow({ |
| 261 | tableId: selectedTable.id, |
| 262 | table: selectedTable as unknown as Entity, |
| 263 | rowData: payload, |
| 264 | enumArrayColumns, |
| 265 | }) |
| 266 | } catch (error: any) { |
| 267 | saveRowError = error |
| 268 | } |
| 269 | } else { |
| 270 | const hasChanges = !isEmpty(payload) |
| 271 | if (hasChanges) { |
| 272 | if (selectedTable.primary_keys.length > 0) { |
| 273 | const row = getRowFromSidePanel(snap.sidePanel) |
| 274 | |
| 275 | if (!row) { |
| 276 | saveRowError = new Error('No row found') |
| 277 | toast.error('No row found') |
| 278 | onComplete(saveRowError) |
| 279 | return |
| 280 | } |
| 281 | |
| 282 | try { |
| 283 | await updateRow({ |
| 284 | tableId: selectedTable.id, |
| 285 | table: selectedTable as unknown as Entity, |
| 286 | row, |
| 287 | rowIdentifiers: configuration.identifiers, |
| 288 | payload, |
| 289 | enumArrayColumns, |
| 290 | onSuccess: () => toast.success('Successfully updated row'), |
| 291 | }) |
| 292 | } catch (error: any) { |
| 293 | saveRowError = error |
| 294 | } |
| 295 | } else { |
| 296 | saveRowError = new Error('No primary key') |
| 297 | toast.error( |
| 298 | "We can't make changes to this table because there is no primary key. Please create a primary key and try again." |
| 299 | ) |
| 300 | } |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | onComplete(saveRowError) |
| 305 | if (!saveRowError) { |
| 306 | setIsEdited(false) |
| 307 | if (!configuration.createMore) snap.closeSidePanel() |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | const onSaveColumnValue = async (value: string | number | null, resolve: () => void) => { |
| 312 | if (selectedTable === undefined) return |
| 313 | |
| 314 | let payload |
| 315 | let configuration |
| 316 | const isNewRecord = false |
| 317 | const identifiers = {} as Dictionary<any> |
| 318 | if (snap.sidePanel?.type === 'json') { |
| 319 | const selectedValueForJsonEdit = snap.sidePanel.jsonValue |
| 320 | const { row, column } = selectedValueForJsonEdit |
| 321 | payload = { [column]: value === null ? null : JSON.parse(value as any) } |
| 322 | selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) |
| 323 | configuration = { identifiers, rowIdx: row.idx } |
| 324 | } else if (snap.sidePanel?.type === 'cell') { |
| 325 | const column = snap.sidePanel.value?.column |
| 326 | const row = snap.sidePanel.value?.row |
| 327 | |
| 328 | if (!column || !row) return |
| 329 | payload = { [column]: value === null ? null : value } |
| 330 | selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) |
| 331 | configuration = { identifiers, rowIdx: row.idx } |
| 332 | } |
| 333 | |
| 334 | if (payload !== undefined && configuration !== undefined) { |
| 335 | try { |
| 336 | await saveRow(payload, isNewRecord, configuration, () => {}) |
| 337 | } catch (error) { |
| 338 | // [Joshen] No error handler required as error is handled within saveRow |
| 339 | } finally { |
| 340 | resolve() |
| 341 | } |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | const onSaveForeignRow = async (value?: { [key: string]: any }) => { |
| 346 | if (selectedTable === undefined || !(snap.sidePanel?.type === 'foreign-row-selector')) return |
| 347 | const selectedForeignKeyToEdit = snap.sidePanel.foreignKey |
| 348 | |
| 349 | try { |
| 350 | const { row } = selectedForeignKeyToEdit |
| 351 | const identifiers = {} as Dictionary<any> |
| 352 | selectedTable.primary_keys.forEach((column) => { |
| 353 | const col = selectedTable.columns?.find((x) => x.name === column.name) |
| 354 | identifiers[column.name] = |
| 355 | col?.format === 'bytea' ? convertByteaToHex(row![column.name]) : row![column.name] |
| 356 | }) |
| 357 | |
| 358 | const isNewRecord = false |
| 359 | const configuration = { identifiers, rowIdx: row.idx } |
| 360 | |
| 361 | await saveRow(value, isNewRecord, configuration, (error) => { |
| 362 | if (error) { |
| 363 | toast.error(`Failed to save row: ${error?.message ?? 'Unknown error'}`) |
| 364 | } |
| 365 | }) |
| 366 | } catch (error: any) { |
| 367 | toast.error(`Failed to save row: ${error?.message ?? 'Unknown error'}`) |
| 368 | Sentry.captureException(error, { tags: { workflow: 'save-foreign-row' } }) |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | const saveColumn = async ( |
| 373 | payload: CreateColumnPayload | UpdateColumnPayload, |
| 374 | isNewRecord: boolean, |
| 375 | configuration: { |
| 376 | columnId?: string |
| 377 | primaryKey?: Constraint |
| 378 | foreignKeyRelations: ForeignKey[] |
| 379 | existingForeignKeyRelations: ForeignKeyConstraint[] |
| 380 | createMore?: boolean |
| 381 | }, |
| 382 | resolve: any |
| 383 | ) => { |
| 384 | const selectedColumnToEdit = |
| 385 | snap.sidePanel?.type === 'column' ? snap.sidePanel.column : undefined |
| 386 | const { primaryKey, foreignKeyRelations, existingForeignKeyRelations } = configuration |
| 387 | |
| 388 | if (!project || selectedTable === undefined) { |
| 389 | return console.error('no project or table selected') |
| 390 | } |
| 391 | |
| 392 | let response |
| 393 | if (isNewRecord) { |
| 394 | response = await createColumn({ |
| 395 | projectRef: project.ref, |
| 396 | connectionString: project.connectionString, |
| 397 | payload: payload as CreateColumnPayload, |
| 398 | selectedTable, |
| 399 | primaryKey, |
| 400 | foreignKeyRelations, |
| 401 | }) |
| 402 | } else { |
| 403 | if (!selectedColumnToEdit) { |
| 404 | return console.error('no column selected to update') |
| 405 | } |
| 406 | response = await updateColumn({ |
| 407 | projectRef: project.ref, |
| 408 | connectionString: project.connectionString, |
| 409 | originalColumn: selectedColumnToEdit, |
| 410 | payload: payload as UpdateColumnPayload, |
| 411 | selectedTable, |
| 412 | primaryKey, |
| 413 | foreignKeyRelations, |
| 414 | existingForeignKeyRelations, |
| 415 | }) |
| 416 | } |
| 417 | |
| 418 | if (response?.error) { |
| 419 | toast.error(response.error.message) |
| 420 | } else { |
| 421 | if ( |
| 422 | !isNewRecord && |
| 423 | payload.name && |
| 424 | selectedColumnToEdit && |
| 425 | selectedColumnToEdit.name !== payload.name |
| 426 | ) { |
| 427 | reAddRenamedColumnSortAndFilter(selectedColumnToEdit.name, payload.name) |
| 428 | } |
| 429 | |
| 430 | await Promise.all([ |
| 431 | queryClient.invalidateQueries({ |
| 432 | queryKey: tableEditorKeys.tableEditor(project?.ref, selectedTable?.id), |
| 433 | }), |
| 434 | queryClient.invalidateQueries({ |
| 435 | queryKey: databaseKeys.foreignKeyConstraints(project?.ref, selectedTable?.schema), |
| 436 | }), |
| 437 | queryClient.invalidateQueries({ |
| 438 | queryKey: databaseKeys.tableDefinition(project?.ref, selectedTable?.id), |
| 439 | }), |
| 440 | queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }), |
| 441 | queryClient.invalidateQueries({ |
| 442 | queryKey: tableKeys.list(project?.ref, selectedTable?.schema, includeColumns), |
| 443 | }), |
| 444 | ]) |
| 445 | |
| 446 | // We need to invalidate tableRowsAndCount after tableEditor |
| 447 | // to ensure the query sent is correct |
| 448 | await queryClient.invalidateQueries({ |
| 449 | queryKey: tableRowKeys.tableRowsAndCount(project?.ref, selectedTable?.id), |
| 450 | }) |
| 451 | |
| 452 | setIsEdited(false) |
| 453 | if (!configuration.createMore) snap.closeSidePanel() |
| 454 | } |
| 455 | |
| 456 | resolve(response?.error) |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Adds the renamed column's filter and/or sort rules. |
| 461 | */ |
| 462 | const reAddRenamedColumnSortAndFilter = (oldColumnName: string, newColumnName: string) => { |
| 463 | setParams((prevParams) => { |
| 464 | const existingFilters = (prevParams?.filter ?? []) as string[] |
| 465 | const existingSorts = (prevParams?.sort ?? []) as string[] |
| 466 | |
| 467 | return { |
| 468 | ...prevParams, |
| 469 | filter: existingFilters.map((filter: string) => { |
| 470 | const [column] = filter.split(':') |
| 471 | return column === oldColumnName ? filter.replace(column, newColumnName) : filter |
| 472 | }), |
| 473 | sort: existingSorts.map((sort: string) => { |
| 474 | const [column] = sort.split(':') |
| 475 | return column === oldColumnName ? sort.replace(column, newColumnName) : sort |
| 476 | }), |
| 477 | } |
| 478 | }) |
| 479 | } |
| 480 | |
| 481 | const updateTableRealtime = async (table: RetrieveTableResult, enabled: boolean) => { |
| 482 | if (!project) return console.error('Project is required') |
| 483 | const realtimePublication = publications?.find((pub) => pub.name === 'briven_realtime') |
| 484 | |
| 485 | try { |
| 486 | if (realtimePublication === undefined) { |
| 487 | const realtimeTables = enabled ? [`${table.schema}.${table.name}`] : [] |
| 488 | await createPublication({ |
| 489 | projectRef: project.ref, |
| 490 | connectionString: project.connectionString, |
| 491 | name: 'briven_realtime', |
| 492 | publish_insert: true, |
| 493 | publish_update: true, |
| 494 | publish_delete: true, |
| 495 | tables: realtimeTables, |
| 496 | }) |
| 497 | |
| 498 | track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', { |
| 499 | method: 'ui', |
| 500 | schema_name: table.schema, |
| 501 | table_name: table.name, |
| 502 | }) |
| 503 | return |
| 504 | } |
| 505 | if (realtimePublication.tables === null) { |
| 506 | // UI doesn't have support for toggling realtime for ALL tables |
| 507 | // Switch it to individual tables via an array of strings |
| 508 | // Refer to PublicationStore for more information about this |
| 509 | const publicTables = await queryClient.fetchQuery({ |
| 510 | queryKey: tableKeys.list(project.ref, 'public', includeColumns), |
| 511 | queryFn: ({ signal }) => |
| 512 | getTables( |
| 513 | { |
| 514 | projectRef: project.ref, |
| 515 | connectionString: project.connectionString, |
| 516 | schema: 'public', |
| 517 | }, |
| 518 | signal |
| 519 | ), |
| 520 | }) |
| 521 | // TODO: support tables in non-public schemas |
| 522 | const realtimeTables = enabled |
| 523 | ? publicTables.map((t) => `${t.schema}.${t.name}`) |
| 524 | : publicTables.filter((t) => t.id !== table.id).map((t) => `${t.schema}.${t.name}`) |
| 525 | await updatePublication({ |
| 526 | id: realtimePublication.id, |
| 527 | projectRef: project.ref, |
| 528 | connectionString: project.connectionString, |
| 529 | tables: realtimeTables, |
| 530 | }) |
| 531 | |
| 532 | track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', { |
| 533 | method: 'ui', |
| 534 | schema_name: table.schema, |
| 535 | table_name: table.name, |
| 536 | }) |
| 537 | return |
| 538 | } |
| 539 | const isAlreadyEnabled = realtimePublication.tables.some((x) => x.id == table.id) |
| 540 | const realtimeTables = |
| 541 | isAlreadyEnabled && !enabled |
| 542 | ? // Toggle realtime off |
| 543 | realtimePublication.tables |
| 544 | .filter((t) => t.id !== table.id) |
| 545 | .map((t) => `${t.schema}.${t.name}`) |
| 546 | : !isAlreadyEnabled && enabled |
| 547 | ? // Toggle realtime on |
| 548 | realtimePublication.tables |
| 549 | .map((t) => `${t.schema}.${t.name}`) |
| 550 | .concat([`${table.schema}.${table.name}`]) |
| 551 | : null |
| 552 | if (realtimeTables === null) return |
| 553 | await updatePublication({ |
| 554 | id: realtimePublication.id, |
| 555 | projectRef: project.ref, |
| 556 | connectionString: project.connectionString, |
| 557 | tables: realtimeTables, |
| 558 | }) |
| 559 | |
| 560 | track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', { |
| 561 | method: 'ui', |
| 562 | schema_name: table.schema, |
| 563 | table_name: table.name, |
| 564 | }) |
| 565 | } catch (error: any) { |
| 566 | toast.error(`Failed to update realtime for ${table.name}: ${error.message}`) |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | const updateTableApiAccess = async ( |
| 571 | table: RetrieveTableResult, |
| 572 | privileges: DeepReadonly<ApiPrivilegesByRole> |
| 573 | ) => { |
| 574 | if (!project) return console.error('Project is required') |
| 575 | |
| 576 | try { |
| 577 | await updateApiPrivileges({ |
| 578 | projectRef: project.ref, |
| 579 | connectionString: project.connectionString ?? undefined, |
| 580 | relationId: table.id, |
| 581 | privileges, |
| 582 | }) |
| 583 | } catch (error) { |
| 584 | const message = error instanceof Error ? error.message : undefined |
| 585 | const toastDetail = message ? `: ${message}` : '' |
| 586 | toast.error(`Failed to update API access privileges for ${table.name}${toastDetail}`) |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | const saveTable = async ({ |
| 591 | action, |
| 592 | payload, |
| 593 | configuration, |
| 594 | columns, |
| 595 | foreignKeyRelations, |
| 596 | generatedPolicies = [], |
| 597 | resolve, |
| 598 | }: SaveTableParams) => { |
| 599 | let toastId |
| 600 | let saveTableError = false |
| 601 | |
| 602 | if (!apiAccessToggleHandler.isSuccess) { |
| 603 | if (apiAccessToggleHandler.isPending) { |
| 604 | toast.info( |
| 605 | 'Cannot save table yet because Data API settings are still loading. Please try again in a moment.' |
| 606 | ) |
| 607 | } else { |
| 608 | toast.error( |
| 609 | 'Cannot save table because there was an error loading Data API settings. Please refresh the page and try again.' |
| 610 | ) |
| 611 | } |
| 612 | return |
| 613 | } |
| 614 | |
| 615 | const { |
| 616 | importContent, |
| 617 | isRLSEnabled, |
| 618 | isRealtimeEnabled, |
| 619 | isDuplicateRows, |
| 620 | existingForeignKeyRelations, |
| 621 | primaryKey, |
| 622 | } = configuration |
| 623 | |
| 624 | try { |
| 625 | if (action === 'create') { |
| 626 | await Sentry.startSpan( |
| 627 | { |
| 628 | name: 'Create Table', |
| 629 | op: 'db.table.create', |
| 630 | }, |
| 631 | async (createTableSpan) => { |
| 632 | toastId = toast.loading(`Creating new table: ${payload.name}...`) |
| 633 | |
| 634 | // Get existing table count from cache — try entity types first (always loaded |
| 635 | // by the Table Editor sidebar), then fall back to tables query cache. |
| 636 | // Entity types uses useInfiniteQuery, so the cache shape is { pages: [...] }. |
| 637 | // Each page has data.count (total count from SQL count(*) over()). |
| 638 | const entityTypesEntries = queryClient.getQueriesData<{ |
| 639 | pages?: Array<{ data?: { count?: number } }> |
| 640 | }>({ |
| 641 | queryKey: ['projects', project?.ref, 'entity-types'], |
| 642 | }) |
| 643 | const existingTableCount = |
| 644 | entityTypesEntries |
| 645 | .map(([, data]) => data?.pages?.[0]?.data?.count) |
| 646 | .find((count) => typeof count === 'number') ?? |
| 647 | queryClient.getQueryData<unknown[]>( |
| 648 | tableKeys.list(project?.ref, payload.schema, true) |
| 649 | )?.length ?? |
| 650 | queryClient.getQueryData<unknown[]>( |
| 651 | tableKeys.list(project?.ref, payload.schema, false) |
| 652 | )?.length |
| 653 | |
| 654 | createTableSpan.setAttributes({ |
| 655 | 'table.name': payload.name, |
| 656 | 'table.schema': payload.schema ?? 'public', |
| 657 | 'table.columns_count': columns.length, |
| 658 | 'table.has_rls': isRLSEnabled ? 1 : 0, |
| 659 | 'table.has_foreign_keys': foreignKeyRelations.length > 0 ? 1 : 0, |
| 660 | 'table.has_import': importContent !== undefined ? 1 : 0, |
| 661 | 'table.generated_policies_count': generatedPolicies.length, |
| 662 | 'project.region': project?.region ?? 'local', |
| 663 | ...(project?.cloud_provider && { |
| 664 | 'project.cloud_provider': project.cloud_provider, |
| 665 | }), |
| 666 | ...(existingTableCount != null && { |
| 667 | 'project.existing_table_count': String(existingTableCount), |
| 668 | }), |
| 669 | }) |
| 670 | |
| 671 | try { |
| 672 | // The Save click is the explicit user gesture that promotes generated policy |
| 673 | // SQL (programmatic or AI) to executable. Programmatic fragments are already |
| 674 | // SafeSqlFragment; AI fragments are UntrustedSqlFragment — both are accepted |
| 675 | // here before being passed into createTable. |
| 676 | const acceptedPolicies = generatedPolicies.map(acceptGeneratedPolicy) |
| 677 | |
| 678 | const { table, failedPolicies } = await createTable({ |
| 679 | projectRef: project?.ref!, |
| 680 | connectionString: project?.connectionString, |
| 681 | toastId, |
| 682 | payload, |
| 683 | columns, |
| 684 | foreignKeyRelations, |
| 685 | isRLSEnabled, |
| 686 | importContent, |
| 687 | organizationSlug: org?.slug, |
| 688 | generatedPolicies: acceptedPolicies, |
| 689 | onCreatePoliciesSuccess: () => track('rls_generated_policies_created'), |
| 690 | }) |
| 691 | |
| 692 | createTableSpan.setAttribute('table.created', 1) |
| 693 | createTableSpan.setAttribute('table.failed_policies', failedPolicies.length) |
| 694 | |
| 695 | await Sentry.startSpan( |
| 696 | { name: 'create_table.post_creation', op: 'db.table.post_creation' }, |
| 697 | async () => { |
| 698 | if (isRealtimeEnabled) await updateTableRealtime(table, true) |
| 699 | |
| 700 | const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed |
| 701 | ? apiAccessToggleHandler.data.privileges |
| 702 | : undefined |
| 703 | if (privilegesToSet) { |
| 704 | await updateTableApiAccess(table, privilegesToSet) |
| 705 | } |
| 706 | } |
| 707 | ) |
| 708 | |
| 709 | // Invalidate queries for table creation |
| 710 | await Sentry.startSpan( |
| 711 | { name: 'create_table.cache_invalidation', op: 'cache.invalidate' }, |
| 712 | async () => { |
| 713 | await Promise.all([ |
| 714 | queryClient.invalidateQueries({ |
| 715 | queryKey: tableKeys.list(project?.ref, table.schema, includeColumns), |
| 716 | }), |
| 717 | queryClient.invalidateQueries({ |
| 718 | queryKey: entityTypeKeys.list(project?.ref), |
| 719 | }), |
| 720 | queryClient.invalidateQueries({ |
| 721 | queryKey: databasePoliciesKeys.list(project?.ref), |
| 722 | }), |
| 723 | queryClient.invalidateQueries({ |
| 724 | queryKey: privilegeKeys.tablePrivilegesList(project?.ref), |
| 725 | }), |
| 726 | queryClient.invalidateQueries({ queryKey: lintKeys.lint(project?.ref) }), |
| 727 | ]) |
| 728 | } |
| 729 | ) |
| 730 | |
| 731 | // Show success toast after everything is complete |
| 732 | if (failedPolicies.length > 0) { |
| 733 | toast.success( |
| 734 | `Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`, |
| 735 | { |
| 736 | id: toastId, |
| 737 | description: ( |
| 738 | <ul className="list-disc pl-6"> |
| 739 | {failedPolicies.map((x) => ( |
| 740 | <li key={x.name}>{x.name}</li> |
| 741 | ))} |
| 742 | </ul> |
| 743 | ), |
| 744 | } |
| 745 | ) |
| 746 | } else { |
| 747 | toast.success(`Table ${table.name} is good to go!`, { id: toastId }) |
| 748 | } |
| 749 | |
| 750 | onTableCreated(table) |
| 751 | } catch (error) { |
| 752 | createTableSpan.setAttribute('table.error', 1) |
| 753 | Sentry.captureException(error, { |
| 754 | tags: { workflow: 'create-table' }, |
| 755 | }) |
| 756 | saveTableError = true |
| 757 | throw error |
| 758 | } |
| 759 | } |
| 760 | ) |
| 761 | } else if (action === 'duplicate' && !!selectedTable) { |
| 762 | const tableToDuplicate = selectedTable |
| 763 | toastId = toast.loading(`Duplicating table: ${tableToDuplicate.name}...`) |
| 764 | |
| 765 | const table = await duplicateTable(project?.ref!, project?.connectionString, payload, { |
| 766 | isRLSEnabled, |
| 767 | isDuplicateRows, |
| 768 | duplicateTable: tableToDuplicate, |
| 769 | foreignKeyRelations, |
| 770 | }) |
| 771 | if (isRealtimeEnabled) await updateTableRealtime(table, isRealtimeEnabled) |
| 772 | |
| 773 | const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed |
| 774 | ? apiAccessToggleHandler.data.privileges |
| 775 | : undefined |
| 776 | if (privilegesToSet) { |
| 777 | await updateTableApiAccess(table, privilegesToSet) |
| 778 | } |
| 779 | |
| 780 | await Promise.all([ |
| 781 | queryClient.invalidateQueries({ |
| 782 | queryKey: tableKeys.list(project?.ref, table.schema, includeColumns), |
| 783 | }), |
| 784 | queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }), |
| 785 | queryClient.invalidateQueries({ |
| 786 | queryKey: privilegeKeys.tablePrivilegesList(project?.ref), |
| 787 | }), |
| 788 | queryClient.invalidateQueries({ queryKey: lintKeys.lint(project?.ref) }), |
| 789 | ]) |
| 790 | |
| 791 | toast.success( |
| 792 | `Table ${tableToDuplicate.name} has been successfully duplicated into ${table.name}!`, |
| 793 | { id: toastId } |
| 794 | ) |
| 795 | onTableCreated(table) |
| 796 | } else if (action === 'update' && selectedTable) { |
| 797 | toastId = toast.loading(`Updating table: ${selectedTable.name}...`) |
| 798 | |
| 799 | const { table, hasError } = await updateTable({ |
| 800 | projectRef: project?.ref!, |
| 801 | connectionString: project?.connectionString, |
| 802 | toastId, |
| 803 | table: selectedTable, |
| 804 | payload, |
| 805 | columns, |
| 806 | foreignKeyRelations, |
| 807 | existingForeignKeyRelations, |
| 808 | primaryKey, |
| 809 | organizationSlug: org?.slug, |
| 810 | }) |
| 811 | |
| 812 | if (table === undefined) { |
| 813 | return toast.error('Failed to update table') |
| 814 | } |
| 815 | if (isTableLike(table)) { |
| 816 | await updateTableRealtime(table, isRealtimeEnabled) |
| 817 | const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed |
| 818 | ? apiAccessToggleHandler.data.privileges |
| 819 | : undefined |
| 820 | if (privilegesToSet) { |
| 821 | await updateTableApiAccess(table, privilegesToSet) |
| 822 | } |
| 823 | } |
| 824 | |
| 825 | if (hasError) { |
| 826 | toast.warning( |
| 827 | `Table ${table.name} has been updated but there were some errors. Please check these errors separately.` |
| 828 | ) |
| 829 | } else { |
| 830 | if (ref && payload.name) { |
| 831 | // [Joshen] Only table entities can be updated via the dashboard |
| 832 | const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id }) |
| 833 | tabsSnap.updateTab(tabId, { label: payload.name }) |
| 834 | } |
| 835 | toast.success(`Successfully updated ${table.name}!`, { id: toastId }) |
| 836 | } |
| 837 | } |
| 838 | } catch (error: any) { |
| 839 | saveTableError = true |
| 840 | toast.error(error.message, { id: toastId }) |
| 841 | } |
| 842 | |
| 843 | if (!saveTableError) { |
| 844 | setIsEdited(false) |
| 845 | snap.closeSidePanel() |
| 846 | } |
| 847 | |
| 848 | resolve() |
| 849 | } |
| 850 | |
| 851 | const onImportData = async (importContent: ImportContent) => { |
| 852 | if (!project || selectedTable === undefined) { |
| 853 | return console.error('no project or table selected') |
| 854 | } |
| 855 | |
| 856 | const { file, rowCount, selectedHeaders, emptyStringAsNullHeaders, resolve } = importContent |
| 857 | const toastId = toast.loading( |
| 858 | `Adding ${rowCount.toLocaleString()} rows to ${selectedTable.name}` |
| 859 | ) |
| 860 | |
| 861 | if (file && rowCount > 0) { |
| 862 | const res = await insertRowsViaSpreadsheet({ |
| 863 | projectRef: project.ref!, |
| 864 | connectionString: project.connectionString, |
| 865 | file, |
| 866 | table: selectedTable, |
| 867 | selectedHeaders, |
| 868 | emptyStringAsNullHeaders, |
| 869 | onProgressUpdate: (progress: number) => { |
| 870 | toast.loading( |
| 871 | <SonnerProgress |
| 872 | progress={progress} |
| 873 | message={`Adding ${rowCount.toLocaleString()} rows to ${selectedTable.name}`} |
| 874 | />, |
| 875 | { id: toastId } |
| 876 | ) |
| 877 | }, |
| 878 | }) |
| 879 | if (res.error) { |
| 880 | const message = isObjectContainingKeys(res.error, ['message']) |
| 881 | ? res.error.message |
| 882 | : 'An unknown error occurred during import' |
| 883 | toast.error(`Failed to import data: ${message}`, { id: toastId }) |
| 884 | return resolve() |
| 885 | } |
| 886 | } else { |
| 887 | const res = await insertTableRows({ |
| 888 | projectRef: project.ref!, |
| 889 | connectionString: project.connectionString, |
| 890 | table: selectedTable, |
| 891 | rows: importContent.rows, |
| 892 | selectedHeaders, |
| 893 | emptyStringAsNullHeaders, |
| 894 | onProgressUpdate: (progress: number) => { |
| 895 | toast.loading( |
| 896 | <SonnerProgress |
| 897 | progress={progress} |
| 898 | message={`Adding ${importContent.rows.length.toLocaleString()} rows to ${ |
| 899 | selectedTable.name |
| 900 | }`} |
| 901 | />, |
| 902 | { id: toastId } |
| 903 | ) |
| 904 | }, |
| 905 | }) |
| 906 | if (res.error) { |
| 907 | const message = isObjectContainingKeys(res.error, ['message']) |
| 908 | ? res.error.message |
| 909 | : 'An unknown error occurred during import' |
| 910 | toast.error(`Failed to import data: ${message}`, { id: toastId }) |
| 911 | return resolve() |
| 912 | } |
| 913 | } |
| 914 | |
| 915 | await queryClient.invalidateQueries({ |
| 916 | queryKey: tableRowKeys.tableRowsAndCount(project?.ref, selectedTable?.id), |
| 917 | }) |
| 918 | toast.success(`Successfully imported ${rowCount} rows of data into ${selectedTable.name}`, { |
| 919 | id: toastId, |
| 920 | }) |
| 921 | resolve() |
| 922 | snap.closeSidePanel() |
| 923 | } |
| 924 | |
| 925 | const onClosePanel = confirmOnClose |
| 926 | |
| 927 | return ( |
| 928 | <> |
| 929 | {!isUndefined(selectedTable) && ( |
| 930 | <RowEditor |
| 931 | row={snap.sidePanel?.type === 'row' ? snap.sidePanel.row : undefined} |
| 932 | selectedTable={selectedTable} |
| 933 | visible={snap.sidePanel?.type === 'row'} |
| 934 | editable={editable} |
| 935 | closePanel={onClosePanel} |
| 936 | saveChanges={saveRow} |
| 937 | updateEditorDirty={() => setIsEdited(true)} |
| 938 | /> |
| 939 | )} |
| 940 | {!isUndefined(selectedTable) && ( |
| 941 | <ColumnEditor |
| 942 | column={snap.sidePanel?.type === 'column' ? snap.sidePanel.column : undefined} |
| 943 | selectedTable={selectedTable} |
| 944 | visible={snap.sidePanel?.type === 'column'} |
| 945 | closePanel={onClosePanel} |
| 946 | saveChanges={saveColumn} |
| 947 | updateEditorDirty={() => setIsEdited(true)} |
| 948 | /> |
| 949 | )} |
| 950 | <TableEditor |
| 951 | table={ |
| 952 | snap.sidePanel?.type === 'table' && |
| 953 | (snap.sidePanel.mode === 'edit' || snap.sidePanel.mode === 'duplicate') |
| 954 | ? selectedTable |
| 955 | : undefined |
| 956 | } |
| 957 | isDuplicating={isDuplicating} |
| 958 | templateData={ |
| 959 | snap.sidePanel?.type === 'table' && snap.sidePanel.templateData |
| 960 | ? { |
| 961 | ...snap.sidePanel.templateData, |
| 962 | columns: snap.sidePanel.templateData.columns |
| 963 | ? [...snap.sidePanel.templateData.columns] |
| 964 | : undefined, |
| 965 | } |
| 966 | : undefined |
| 967 | } |
| 968 | visible={snap.sidePanel?.type === 'table'} |
| 969 | closePanel={onClosePanel} |
| 970 | saveChanges={saveTable} |
| 971 | updateEditorDirty={() => setIsEdited(true)} |
| 972 | apiAccessToggleHandler={apiAccessToggleHandler} |
| 973 | /> |
| 974 | <SchemaEditor |
| 975 | visible={snap.sidePanel?.type === 'schema'} |
| 976 | onSuccess={onClosePanel} |
| 977 | closePanel={onClosePanel} |
| 978 | /> |
| 979 | <JsonEditor |
| 980 | visible={snap.sidePanel?.type === 'json'} |
| 981 | row={(snap.sidePanel?.type === 'json' && snap.sidePanel.jsonValue.row) || {}} |
| 982 | column={(snap.sidePanel?.type === 'json' && snap.sidePanel.jsonValue.column) || ''} |
| 983 | backButtonLabel="Cancel" |
| 984 | applyButtonLabel={isQueueOperationsEnabled ? 'Queue changes' : 'Save changes'} |
| 985 | readOnly={!editable} |
| 986 | closePanel={onClosePanel} |
| 987 | onSaveJSON={onSaveColumnValue} |
| 988 | /> |
| 989 | <TextEditor |
| 990 | visible={snap.sidePanel?.type === 'cell'} |
| 991 | column={(snap.sidePanel?.type === 'cell' && snap.sidePanel.value?.column) || ''} |
| 992 | row={(snap.sidePanel?.type === 'cell' && snap.sidePanel.value?.row) || {}} |
| 993 | closePanel={onClosePanel} |
| 994 | onSaveField={onSaveColumnValue} |
| 995 | /> |
| 996 | <ForeignRowSelector |
| 997 | visible={snap.sidePanel?.type === 'foreign-row-selector'} |
| 998 | // @ts-ignore |
| 999 | foreignKey={ |
| 1000 | snap.sidePanel?.type === 'foreign-row-selector' |
| 1001 | ? snap.sidePanel.foreignKey.foreignKey |
| 1002 | : undefined |
| 1003 | } |
| 1004 | isSaving={isEditPending} |
| 1005 | closePanel={onClosePanel} |
| 1006 | onSelect={onSaveForeignRow} |
| 1007 | /> |
| 1008 | <SpreadsheetImport |
| 1009 | key={csvImportKey} |
| 1010 | visible={snap.sidePanel?.type === 'csv-import'} |
| 1011 | selectedTable={selectedTable} |
| 1012 | saveContent={onImportData} |
| 1013 | closePanel={onClosePanel} |
| 1014 | updateEditorDirty={setIsEdited} |
| 1015 | /> |
| 1016 | <OperationQueueSidePanel /> |
| 1017 | <DiscardChangesConfirmationDialog {...modalProps} /> |
| 1018 | </> |
| 1019 | ) |
| 1020 | } |