SidePanelEditor.tsx 36 KB

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