Files
supabase/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx

476 lines
14 KiB
TypeScript

import { useState } from 'react'
import { QueryKey, useQueryClient } from '@tanstack/react-query'
import { find, isEmpty, isUndefined, noop } from 'lodash'
import { Dictionary } from 'components/grid'
import { Modal } from 'ui'
import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta'
import { useStore } from 'hooks'
import { entityTypeKeys } from 'data/entity-types/keys'
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
import { RowEditor, ColumnEditor, TableEditor } from '.'
import { ImportContent } from './TableEditor/TableEditor.types'
import {
ColumnField,
CreateColumnPayload,
ExtendedPostgresRelationship,
UpdateColumnPayload,
} from './SidePanelEditor.types'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import JsonEdit from './RowEditor/JsonEditor/JsonEditor'
import { JsonEditValue } from './RowEditor/RowEditor.types'
import { sqlKeys } from 'data/sql/keys'
import ForeignRowSelector, {
ForeignRowSelectorProps,
} from './RowEditor/ForeignRowSelector/ForeignRowSelector'
export interface SidePanelEditorProps {
selectedSchema: string
selectedTable?: PostgresTable
selectedRowToEdit?: Dictionary<any>
selectedColumnToEdit?: PostgresColumn
selectedTableToEdit?: PostgresTable
selectedValueForJsonEdit?: JsonEditValue
selectedForeignKeyToEdit?: {
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
row: any
column: any
}
sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector'
isDuplicating?: boolean
closePanel: () => void
onRowCreated?: (row: Dictionary<any>) => void
onRowUpdated?: (row: Dictionary<any>, idx: number) => void
// Because the panel is shared between grid editor and database pages
// Both require different responses upon success of these events
onTableCreated?: (table: PostgresTable) => void
onColumnSaved?: (hasEncryptedColumns?: boolean) => void
}
const SidePanelEditor = ({
selectedSchema,
selectedTable,
selectedRowToEdit,
selectedColumnToEdit,
selectedTableToEdit,
selectedValueForJsonEdit,
selectedForeignKeyToEdit,
sidePanelKey,
isDuplicating = false,
closePanel,
onRowCreated = noop,
onRowUpdated = noop,
onTableCreated = noop,
onColumnSaved = noop,
}: SidePanelEditorProps) => {
const { meta, ui } = useStore()
const queryClient = useQueryClient()
const [isEdited, setIsEdited] = useState<boolean>(false)
const [isClosingPanel, setIsClosingPanel] = useState<boolean>(false)
const tables = meta.tables.list()
const { project } = useProjectContext()
const { mutateAsync: createTableRow } = useTableRowCreateMutation()
const { mutateAsync: updateTableRow } = useTableRowUpdateMutation({
async onMutate({ projectRef, table, configuration, payload }) {
closePanel()
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
const queryKey = sqlKeys.query(projectRef, [
table.schema,
table.name,
{ table: { name: table.name, schema: table.schema } },
])
await queryClient.cancelQueries(queryKey)
const previousRowsQueries = queryClient.getQueriesData<{ result: any[] }>(queryKey)
queryClient.setQueriesData<{ result: any[] }>(queryKey, (old) => {
return {
result:
old?.result.map((row) => {
// match primary keys
if (
Object.entries(row)
.filter(([key]) => primaryKeyColumns.has(key))
.every(([key, value]) => value === configuration.identifiers[key])
) {
return { ...row, ...payload }
}
return row
}) ?? [],
}
})
return { previousRowsQueries }
},
onError(error, _variables, context) {
const { previousRowsQueries } = context as {
previousRowsQueries: [
QueryKey,
(
| {
result: any[]
}
| undefined
)
][]
}
previousRowsQueries.forEach(([queryKey, previousRows]) => {
if (previousRows) {
queryClient.setQueriesData(queryKey, previousRows)
}
queryClient.invalidateQueries(queryKey)
})
},
})
const saveRow = async (
payload: any,
isNewRecord: boolean,
configuration: { identifiers: any; rowIdx: number },
onComplete: Function
) => {
if (!project || selectedTable === undefined) {
return console.error('no project or table selected')
}
let saveRowError = false
// @ts-ignore
const enumArrayColumns = selectedTable.columns
.filter((column) => {
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
})
.map((column) => column.name)
if (isNewRecord) {
try {
const result = await createTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
payload,
enumArrayColumns,
})
onRowCreated(result[0])
} catch (error: any) {
saveRowError = true
ui.setNotification({ category: 'error', message: error?.message })
}
} else {
const hasChanges = !isEmpty(payload)
if (hasChanges) {
if (selectedTable.primary_keys.length > 0) {
if (selectedTable!.primary_keys.length > 0) {
try {
const result = await updateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
configuration,
payload,
enumArrayColumns,
})
onRowUpdated(result[0], configuration.rowIdx)
} catch (error: any) {
saveRowError = true
ui.setNotification({ category: 'error', message: error?.message })
}
}
} else {
saveRowError = true
ui.setNotification({
category: 'error',
message:
"We can't make changes to this table because there is no primary key. Please create a primary key and try again.",
})
}
}
}
onComplete()
if (!saveRowError) {
setIsEdited(false)
closePanel()
}
}
const onSaveJSON = async (value: string | number) => {
if (selectedTable === undefined || selectedValueForJsonEdit === undefined) return
try {
const { row, column } = selectedValueForJsonEdit
const payload = { [column]: JSON.parse(value as any) }
const identifiers = {} as Dictionary<any>
selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name]))
const isNewRecord = false
const configuration = { identifiers, rowIdx: row.idx }
saveRow(payload, isNewRecord, configuration, () => {})
} catch (error: any) {}
}
const onSaveForeignRow = async (value: any) => {
if (selectedTable === undefined || selectedForeignKeyToEdit === undefined) return
try {
const { row, column } = selectedForeignKeyToEdit
const payload = { [column.name]: value }
const identifiers = {} as Dictionary<any>
selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name]))
const isNewRecord = false
const configuration = { identifiers, rowIdx: row.idx }
saveRow(payload, isNewRecord, configuration, () => {})
} catch (error) {}
}
const saveColumn = async (
payload: CreateColumnPayload | UpdateColumnPayload,
foreignKey: ExtendedPostgresRelationship | undefined,
isNewRecord: boolean,
configuration: { columnId?: string; isEncrypted: boolean; keyId?: string; keyName?: string },
resolve: any
) => {
const { columnId, ...securityConfig } = configuration
const response = isNewRecord
? await meta.createColumn(
payload as CreateColumnPayload,
selectedTable as PostgresTable,
foreignKey,
securityConfig
)
: await meta.updateColumn(
columnId as string,
payload as UpdateColumnPayload,
selectedTable as PostgresTable,
foreignKey
)
if (response?.error) {
ui.setNotification({ category: 'error', message: response.error.message })
} else {
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
await Promise.all([
meta.tables.loadById(selectedTable!.id),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name])
),
])
onColumnSaved(configuration.isEncrypted)
setIsEdited(false)
closePanel()
}
if (configuration.isEncrypted && selectedTable?.schema) {
await meta.views.loadBySchema(selectedTable.schema)
}
resolve()
}
const saveTable = async (
payload: any,
columns: ColumnField[],
isNewRecord: boolean,
configuration: {
tableId?: number
importContent?: ImportContent
isRLSEnabled: boolean
isRealtimeEnabled: boolean
isDuplicateRows: boolean
},
resolve: any
) => {
let toastId
let saveTableError = false
const { tableId, importContent, isRLSEnabled, isRealtimeEnabled, isDuplicateRows } =
configuration
try {
if (isDuplicating) {
const duplicateTable = find(tables, { id: tableId }) as PostgresTable
toastId = ui.setNotification({
category: 'loading',
message: `Duplicating table: ${duplicateTable.name}...`,
})
const table: any = await meta.duplicateTable(payload, {
isRLSEnabled,
isRealtimeEnabled,
isDuplicateRows,
duplicateTable,
})
await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref))
ui.setNotification({
id: toastId,
category: 'success',
message: `Table ${duplicateTable.name} has been successfully duplicated into ${table.name}!`,
})
onTableCreated(table)
} else if (isNewRecord) {
toastId = ui.setNotification({
category: 'loading',
message: `Creating new table: ${payload.name}...`,
})
const table = await meta.createTable(
toastId,
payload,
columns,
isRLSEnabled,
isRealtimeEnabled,
importContent
)
await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref))
ui.setNotification({
id: toastId,
category: 'success',
message: `Table ${table.name} is good to go!`,
})
onTableCreated(table)
} else if (selectedTableToEdit) {
toastId = ui.setNotification({
category: 'loading',
message: `Updating table: ${selectedTableToEdit?.name}...`,
})
const { table, hasError }: any = await meta.updateTable(
toastId,
selectedTableToEdit,
payload,
columns,
isRealtimeEnabled
)
if (hasError) {
ui.setNotification({
id: toastId,
category: 'info',
message: `Table ${table.name} has been updated, but there were some errors`,
})
} else {
await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref))
ui.setNotification({
id: toastId,
category: 'success',
message: `Successfully updated ${table.name}!`,
})
}
}
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
} catch (error: any) {
saveTableError = true
ui.setNotification({ id: toastId, category: 'error', message: error.message })
}
if (!saveTableError) {
setIsEdited(false)
closePanel()
}
resolve()
}
const onClosePanel = () => {
if (isEdited) {
setIsClosingPanel(true)
} else {
closePanel()
}
}
return (
<>
{!isUndefined(selectedTable) && (
<RowEditor
row={selectedRowToEdit}
selectedTable={selectedTable}
visible={sidePanelKey === 'row'}
closePanel={onClosePanel}
saveChanges={saveRow}
updateEditorDirty={() => setIsEdited(true)}
/>
)}
{!isUndefined(selectedTable) && (
<ColumnEditor
column={selectedColumnToEdit}
selectedTable={selectedTable}
visible={sidePanelKey === 'column'}
closePanel={onClosePanel}
saveChanges={saveColumn}
updateEditorDirty={() => setIsEdited(true)}
/>
)}
<TableEditor
table={selectedTableToEdit}
selectedSchema={selectedSchema}
isDuplicating={isDuplicating}
visible={sidePanelKey === 'table'}
closePanel={onClosePanel}
saveChanges={saveTable}
updateEditorDirty={() => setIsEdited(true)}
/>
<JsonEdit
visible={sidePanelKey === 'json'}
column={selectedValueForJsonEdit?.column ?? ''}
jsonString={selectedValueForJsonEdit?.jsonString ?? ''}
backButtonLabel="Cancel"
applyButtonLabel="Save changes"
closePanel={onClosePanel}
onSaveJSON={onSaveJSON}
/>
<ForeignRowSelector
key={`foreign-row-selector-${selectedForeignKeyToEdit?.foreignKey?.id ?? 'null'}`}
visible={sidePanelKey === 'foreign-row-selector'}
foreignKey={selectedForeignKeyToEdit?.foreignKey}
closePanel={onClosePanel}
onSelect={onSaveForeignRow}
/>
<ConfirmationModal
visible={isClosingPanel}
header="Confirm to close"
buttonLabel="Confirm"
onSelectCancel={() => setIsClosingPanel(false)}
onSelectConfirm={() => {
setIsClosingPanel(false)
setIsEdited(false)
closePanel()
}}
children={
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</Modal.Content>
}
/>
</>
)
}
export default SidePanelEditor