SidePanelEditor.tsx1020 lines · main
1import * as Sentry from '@sentry/nextjs'
2import type { PGTable } from '@supabase/pg-meta'
3import { useQueryClient } from '@tanstack/react-query'
4import { useParams } from 'common'
5import { isEmpty, isUndefined, noop } from 'lodash'
6import { useState } from 'react'
7import { toast } from 'sonner'
8import { SonnerProgress } from 'ui'
9
10import { ColumnEditor } from './ColumnEditor/ColumnEditor'
11import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
12import { OperationQueueSidePanel } from './OperationQueueSidePanel/OperationQueueSidePanel'
13import { ForeignRowSelector } from './RowEditor/ForeignRowSelector/ForeignRowSelector'
14import { JsonEditor } from './RowEditor/JsonEditor'
15import { RowEditor } from './RowEditor/RowEditor'
16import { convertByteaToHex } from './RowEditor/RowEditor.utils'
17import { TextEditor } from './RowEditor/TextEditor'
18import { SchemaEditor } from './SchemaEditor'
19import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types'
20import {
21 createColumn,
22 createTable,
23 duplicateTable,
24 getRowFromSidePanel,
25 insertRowsViaSpreadsheet,
26 insertTableRows,
27 updateColumn,
28 updateTable,
29} from './SidePanelEditor.utils'
30import { SpreadsheetImport } from './SpreadsheetImport/SpreadsheetImport'
31import {
32 useTableApiAccessHandlerWithHistory,
33 type TableApiAccessParams,
34} from './TableEditor/ApiAccessToggle'
35import { TableEditor } from './TableEditor/TableEditor'
36import type { ImportContent } from './TableEditor/TableEditor.types'
37import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations'
38import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
39import {
40 acceptGeneratedPolicy,
41 type GeneratedPolicy,
42} from '@/components/interfaces/Auth/Policies/Policies.utils'
43import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
44import { databasePoliciesKeys } from '@/data/database-policies/keys'
45import { useDatabasePublicationCreateMutation } from '@/data/database-publications/database-publications-create-mutation'
46import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
47import { useDatabasePublicationUpdateMutation } from '@/data/database-publications/database-publications-update-mutation'
48import type { Constraint } from '@/data/database/constraints-query'
49import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
50import { databaseKeys } from '@/data/database/keys'
51import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
52import { entityTypeKeys } from '@/data/entity-types/keys'
53import { lintKeys } from '@/data/lint/keys'
54import { privilegeKeys } from '@/data/privileges/keys'
55import { useTableApiAccessPrivilegesMutation } from '@/data/privileges/table-api-access-mutation'
56import { tableEditorKeys } from '@/data/table-editor/keys'
57import { isTableLike, type Entity } from '@/data/table-editor/table-editor-types'
58import { tableRowKeys } from '@/data/table-rows/keys'
59import { tableKeys } from '@/data/tables/keys'
60import { RetrieveTableResult } from '@/data/tables/table-retrieve-query'
61import { getTables } from '@/data/tables/tables-query'
62import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
63import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
64import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
65import { useUrlState } from '@/hooks/ui/useUrlState'
66import { useVisibleKey } from '@/hooks/ui/useVisibleKey'
67import { type ApiPrivilegesByRole } from '@/lib/data-api-types'
68import { isObjectContainingKeys } from '@/lib/helpers'
69import type { SafePostgresTable } from '@/lib/postgres-types'
70import { useTrack } from '@/lib/telemetry/track'
71import type { DeepReadonly, Prettify } from '@/lib/type-helpers'
72import { useTableEditorStateSnapshot, type TableEditorState } from '@/state/table-editor'
73import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
74import type { Dictionary } from '@/types'
75
76export type SaveTableParams =
77 | SaveTableParamsNew
78 | SaveTableParamsDuplicate
79 | SaveTableParamsExisting
80
81type SaveTableParamsBase = {
82 configuration: SaveTableConfiguration
83 columns: ColumnField[]
84 foreignKeyRelations: ForeignKey[]
85 resolve: () => void
86 generatedPolicies?: GeneratedPolicy[]
87}
88
89type SaveTableParamsNew = SaveTableParamsBase & {
90 action: 'create'
91 payload: SaveTablePayloadNew
92}
93
94type SaveTableParamsDuplicate = SaveTableParamsBase & {
95 action: 'duplicate'
96 payload: SaveTablePayloadDuplicate
97}
98
99type SaveTableParamsExisting = SaveTableParamsBase & {
100 action: 'update'
101 payload: SaveTablePayloadExisting
102}
103
104type 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
114type SaveTablePayloadNew = SaveTablePayloadBase & {
115 name: string
116 schema: string
117}
118
119type SaveTablePayloadDuplicate = SaveTablePayloadBase & {
120 name: string
121}
122
123type SaveTablePayloadExisting = SaveTablePayloadBase & {
124 name?: string
125 rls_enabled?: boolean
126}
127
128type 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
138const DUMMY_TABLE_API_ACCESS_PARAMS: TableApiAccessParams = {
139 type: 'new',
140}
141
142const 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
175export 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
185export 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}