refactor to valtio table editor state

This commit is contained in:
Alaister Young committed 2023-05-03 16:37:35 +10:00
1 parent faecbd3736
commit 10e541a2de
13 files changed
+740 -1026

No files matched your search

+160 -152
View File
@@ -25,7 +25,10 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
/** Supabase Grid: React component to render database table */
export const SupabaseGrid = forwardRef<SupabaseGridRef, SupabaseGridProps>((props, ref) => {
export const SupabaseGrid = forwardRef<SupabaseGridRef, SupabaseGridProps>(function SupabaseGrid(
props,
ref
) {
const monaco = useMonaco()
const _props = cleanupProps(props)
const { theme } = _props
@@ -57,166 +60,171 @@ export const SupabaseGrid = forwardRef<SupabaseGridRef, SupabaseGridProps>((prop
)
})
const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props, ref) => {
const {
editable,
storageRef,
gridProps,
headerActions,
showCustomChildren,
customHeader,
children,
onAddRow,
onAddColumn,
updateTableRow,
onEditForeignKeyColumnValue,
onImportData,
} = props
const dispatch = useDispatch()
const state = useTrackedState()
const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>(
function SupabaseGridLayout(props, ref) {
const {
editable,
storageRef,
gridProps,
headerActions,
showCustomChildren,
customHeader,
children,
onAddRow,
onAddColumn,
updateTableRow,
onEditForeignKeyColumnValue,
onImportData,
} = props
const dispatch = useDispatch()
const state = useTrackedState()
const gridRef = useRef<DataGridHandle>(null)
const [mounted, setMounted] = useState(false)
const gridRef = useRef<DataGridHandle>(null)
const [mounted, setMounted] = useState(false)
const [{ sort, filter }, setParams] = useUrlState({
arrayKeys: ['sort', 'filter'],
})
const sorts = formatSortURLParams(sort as string[])
const filters = formatFilterURLParams(filter as string[])
const [{ sort, filter }, setParams] = useUrlState({
arrayKeys: ['sort', 'filter'],
})
const sorts = formatSortURLParams(sort as string[])
const filters = formatFilterURLParams(filter as string[])
const { project } = useProjectContext()
const { data, isLoading, isRefetching } = useTableRowsQuery(
{
queryKey: [props.table.schema, props.table.name],
projectRef: project?.ref,
connectionString: project?.connectionString,
table: props.table,
sorts,
filters,
page: state.page,
limit: state.rowsPerPage,
},
{
keepPreviousData: true,
onSuccess(data) {
const { project } = useProjectContext()
const { data, isLoading, isRefetching } = useTableRowsQuery(
{
queryKey: [props.table.schema, props.table.name],
projectRef: project?.ref,
connectionString: project?.connectionString,
table: props.table,
sorts,
filters,
page: state.page,
limit: state.rowsPerPage,
},
{
keepPreviousData: true,
onSuccess(data) {
dispatch({
type: 'SET_ROWS_COUNT',
payload: data.rows.length,
})
},
}
)
useImperativeHandle(ref, () => ({
rowAdded(row: Dictionary<any>) {
dispatch({
type: 'SET_ROWS_COUNT',
payload: data.rows.length,
type: 'ADD_NEW_ROW',
payload: row,
})
},
}
)
useImperativeHandle(ref, () => ({
rowAdded(row: Dictionary<any>) {
dispatch({
type: 'ADD_NEW_ROW',
payload: row,
})
},
rowEdited(row: Dictionary<any>, idx: number) {
dispatch({
type: 'EDIT_ROW',
payload: { row, idx },
})
},
}))
useEffect(() => {
if (!mounted) setMounted(true)
}, [])
useEffect(() => {
if (mounted) {
dispatch({ type: 'UPDATE_FILTERS', payload: {} })
}
}, [JSON.stringify(filters)])
useEffect(() => {
if (mounted) {
dispatch({ type: 'UPDATE_SORTS', payload: {} })
}
}, [JSON.stringify(sorts)])
useEffect(() => {
if (state.isInitialComplete && storageRef && state.table) {
saveStorageDebounced(state, storageRef, sort as string[], filter as string[])
}
}, [
state.table,
state.isInitialComplete,
state.gridColumns,
JSON.stringify(sorts),
JSON.stringify(filters),
storageRef,
])
useEffect(() => {
dispatch({
type: 'INIT_CALLBACK',
payload: { ...props },
})
}, [])
useEffect(() => {
const initializeData = async () => {
const { savedState } = await initTable(
props,
state,
dispatch,
sort as string[],
filter as string[]
)
if (savedState.sorts || savedState.filters) {
setParams((prevParams) => {
return {
...prevParams,
...(savedState.sorts && { sort: savedState.sorts }),
...(savedState.filters && { filter: savedState.filters }),
}
rowEdited(row: Dictionary<any>, idx: number) {
dispatch({
type: 'EDIT_ROW',
payload: { row, idx },
})
},
}))
useEffect(() => {
if (!mounted) setMounted(true)
}, [])
useEffect(() => {
if (mounted) {
dispatch({ type: 'UPDATE_FILTERS', payload: {} })
}
}
}, [JSON.stringify(filters)])
const refreshTable = JSON.stringify(props.table) !== JSON.stringify(state.table)
useEffect(() => {
if (mounted) {
dispatch({ type: 'UPDATE_SORTS', payload: {} })
}
}, [JSON.stringify(sorts)])
if (!state.table || refreshTable) {
initializeData()
}
}, [state.table, props.table, props.schema])
useEffect(() => {
if (state.isInitialComplete && storageRef && state.table) {
saveStorageDebounced(state, storageRef, sort as string[], filter as string[])
}
}, [
state.table,
state.isInitialComplete,
state.gridColumns,
JSON.stringify(sorts),
JSON.stringify(filters),
storageRef,
])
return (
<div className="sb-grid">
<Header
table={props.table}
sorts={sorts}
filters={filters}
isRefetching={isRefetching}
onAddRow={editable ? onAddRow : undefined}
onAddColumn={editable ? onAddColumn : undefined}
onImportData={editable ? onImportData : undefined}
headerActions={headerActions}
customHeader={customHeader}
/>
{showCustomChildren && children !== undefined ? (
<>{children}</>
) : (
<>
<Grid
ref={gridRef}
{...gridProps}
rows={data?.rows ?? []}
updateRow={updateTableRow}
onEditForeignKeyColumnValue={onEditForeignKeyColumnValue}
/>
<Footer isLoading={isLoading || isRefetching} />
<Shortcuts gridRef={gridRef} />
</>
)}
useEffect(() => {
dispatch({
type: 'INIT_CALLBACK',
payload: { ...props },
})
}, [])
{mounted &&
createPortal(<RowContextMenu table={props.table} rows={data?.rows ?? []} />, document.body)}
</div>
)
})
useEffect(() => {
const initializeData = async () => {
const { savedState } = await initTable(
props,
state,
dispatch,
sort as string[],
filter as string[]
)
if (savedState.sorts || savedState.filters) {
setParams((prevParams) => {
return {
...prevParams,
...(savedState.sorts && { sort: savedState.sorts }),
...(savedState.filters && { filter: savedState.filters }),
}
})
}
}
const refreshTable = JSON.stringify(props.table) !== JSON.stringify(state.table)
if (!state.table || refreshTable) {
initializeData()
}
}, [state.table, props.table, props.schema])
return (
<div className="sb-grid">
<Header
table={props.table}
sorts={sorts}
filters={filters}
isRefetching={isRefetching}
onAddRow={editable ? onAddRow : undefined}
onAddColumn={editable ? onAddColumn : undefined}
onImportData={editable ? onImportData : undefined}
headerActions={headerActions}
customHeader={customHeader}
/>
{showCustomChildren && children !== undefined ? (
<>{children}</>
) : (
<>
<Grid
ref={gridRef}
{...gridProps}
rows={data?.rows ?? []}
updateRow={updateTableRow}
onEditForeignKeyColumnValue={onEditForeignKeyColumnValue}
/>
<Footer isLoading={isLoading || isRefetching} />
<Shortcuts gridRef={gridRef} />
</>
)}
{mounted &&
createPortal(
<RowContextMenu table={props.table} rows={data?.rows ?? []} />,
document.body
)}
</div>
)
}
)
@@ -0,0 +1,236 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { entityTypeKeys } from 'data/entity-types/keys'
import { sqlKeys } from 'data/sql/keys'
import { useStore, useUrlState } from 'hooks'
import { TableLike } from 'hooks/misc/useTable'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { Alert, Button, Checkbox, IconExternalLink, Modal } from 'ui'
export type DeleteConfirmationDialogsProps = {
projectRef?: string
selectedTable?: TableLike
}
const DeleteConfirmationDialogs = ({
projectRef,
selectedTable,
}: DeleteConfirmationDialogsProps) => {
const { project } = useProjectContext()
const snap = useTableEditorStateSnapshot()
const router = useRouter()
const [, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
const queryClient = useQueryClient()
const { meta, ui } = useStore()
const removeDeletedColumnFromFiltersAndSorts = (columnName: string) => {
setParams((prevParams) => {
const existingFilters = (prevParams?.filter ?? []) as string[]
const existingSorts = (prevParams?.sort ?? []) as string[]
return {
...prevParams,
filter: existingFilters.filter((filter: string) => {
const [column] = filter.split(':')
if (column !== columnName) return filter
}),
sort: existingSorts.filter((sort: string) => {
const [column] = sort.split(':')
if (column !== columnName) return sort
}),
}
})
}
const isDeleteWithCascade = snap.confirmationDialog?.isDeleteWithCascade ?? false
const onConfirmDeleteColumn = async () => {
if (!(snap.confirmationDialog?.type === 'column')) return
const selectedColumnToDelete = snap.confirmationDialog.column
try {
if (selectedColumnToDelete === undefined) return
const response: any = await meta.columns.del(selectedColumnToDelete.id, isDeleteWithCascade)
if (response.error) throw response.error
removeDeletedColumnFromFiltersAndSorts(selectedColumnToDelete.name)
ui.setNotification({
category: 'success',
message: `Successfully deleted column "${selectedColumnToDelete.name}"`,
})
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
await Promise.all([
meta.tables.loadById(selectedColumnToDelete!.table_id),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name])
),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [
'table-definition',
selectedTable!.schema,
selectedTable!.name,
])
),
])
if (snap.selectedSchemaName) await meta.views.loadBySchema(snap.selectedSchemaName)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to delete ${selectedColumnToDelete!.name}: ${error.message}`,
})
} finally {
snap.closeConfirmationDialog()
}
}
const onConfirmDeleteTable = async () => {
if (!(snap.confirmationDialog?.type === 'table')) return
const selectedTableToDelete = selectedTable
try {
if (selectedTableToDelete === undefined) return
const response: any = await meta.tables.del(selectedTableToDelete.id, isDeleteWithCascade)
if (response.error) throw response.error
const tables = meta.tables.list(
(table: PostgresTable) => table.schema === snap.selectedSchemaName
)
await queryClient.invalidateQueries(entityTypeKeys.list(projectRef))
// For simplicity for now, we just open the first table within the same schema
if (tables.length > 0) {
router.push(`/project/${projectRef}/editor/${tables[0].id}`)
} else {
router.push(`/project/${projectRef}/editor/`)
}
ui.setNotification({
category: 'success',
message: `Successfully deleted table "${selectedTableToDelete.name}"`,
})
if (snap.selectedSchemaName) await meta.views.loadBySchema(snap.selectedSchemaName)
} catch (error: any) {
ui.setNotification({
error,
category: 'error',
message: `Failed to delete ${selectedTableToDelete?.name}: ${error.message}`,
})
} finally {
snap.closeConfirmationDialog()
}
}
return (
<>
<ConfirmationModal
danger
size="small"
visible={snap.confirmationDialog?.type === 'column'}
header={`Confirm deletion of column "${
snap.confirmationDialog?.type === 'column' && snap.confirmationDialog.column.name
}"`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => {
snap.closeConfirmationDialog()
}}
onSelectConfirm={onConfirmDeleteColumn}
>
<Modal.Content>
<div className="py-4 space-y-4">
<p className="text-sm text-scale-1100">
Are you sure you want to delete the selected column? This action cannot be undone.
</p>
<Checkbox
label="Drop column with cascade?"
description="Deletes the column and its dependent objects"
checked={isDeleteWithCascade}
onChange={() => snap.toggleConfirmationIsWithCascade()}
/>
{isDeleteWithCascade && (
<Alert
withIcon
variant="warning"
title="Warning: Dropping with cascade may result in unintended consequences"
>
<p className="mb-4">
All dependent objects will be removed, as will any objects that depend on them,
recursively.
</p>
<Link href="https://www.postgresql.org/docs/current/ddl-depend.html">
<a target="_blank" rel="noreferrer">
<Button size="tiny" type="default" icon={<IconExternalLink />}>
About dependency tracking
</Button>
</a>
</Link>
</Alert>
)}
</div>
</Modal.Content>
</ConfirmationModal>
<ConfirmationModal
danger
size="small"
visible={snap.confirmationDialog?.type === 'table'}
header={
<span className="break-words">{`Confirm deletion of table "${selectedTable?.name}"`}</span>
}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => {
snap.closeConfirmationDialog()
}}
onSelectConfirm={onConfirmDeleteTable}
>
<Modal.Content>
<div className="py-4 space-y-4">
<p className="text-sm text-scale-1100">
Are you sure you want to delete the selected table? This action cannot be undone.
</p>
<Checkbox
label="Drop table with cascade?"
description="Deletes the table and its dependent objects"
checked={isDeleteWithCascade}
onChange={() => snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)}
/>
{isDeleteWithCascade && (
<Alert
withIcon
variant="warning"
title="Warning: Dropping with cascade may result in unintended consequences"
>
<p className="mb-4">
All dependent objects will be removed, as will any objects that depend on them,
recursively.
</p>
<Link href="https://www.postgresql.org/docs/current/ddl-depend.html">
<a target="_blank" rel="noreferrer">
<Button size="tiny" type="default" icon={<IconExternalLink />}>
About dependency tracking
</Button>
</a>
</Link>
</Alert>
)}
</div>
</Modal.Content>
</ConfirmationModal>
</>
)
}
export default DeleteConfirmationDialogs
@@ -6,11 +6,9 @@ import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-que
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
export interface EmptyStateProps {
onAddTable: () => void
}
export interface EmptyStateProps {}
const EmptyState = ({ onAddTable }: EmptyStateProps) => {
const EmptyState = ({}: EmptyStateProps) => {
const snap = useTableEditorStateSnapshot()
const isProtectedSchema = EXCLUDED_SCHEMAS.includes(snap.selectedSchemaName)
const canCreateTables =
@@ -42,7 +40,7 @@ const EmptyState = ({ onAddTable }: EmptyStateProps) => {
<ProductEmptyState
title="Table Editor"
ctaButtonLabel={canCreateTables ? 'Create a new table' : undefined}
onClickCta={canCreateTables ? onAddTable : undefined}
onClickCta={canCreateTables ? snap.onAddTable : undefined}
>
<p className="text-sm text-scale-1100">There are no tables available in this schema.</p>
</ProductEmptyState>
@@ -51,7 +49,7 @@ const EmptyState = ({ onAddTable }: EmptyStateProps) => {
<ProductEmptyState
title="Table Editor"
ctaButtonLabel={canCreateTables ? 'Create a new table' : undefined}
onClickCta={canCreateTables ? onAddTable : undefined}
onClickCta={canCreateTables ? snap.onAddTable : undefined}
>
<p className="text-sm text-scale-1100">
Select a table from the navigation panel on the left to view its data
@@ -25,21 +25,10 @@ import { sqlKeys } from 'data/sql/keys'
import ForeignRowSelector, {
ForeignRowSelectorProps,
} from './RowEditor/ForeignRowSelector/ForeignRowSelector'
import { useTableEditorStateSnapshot } from 'state/table-editor'
export interface SidePanelEditorProps {
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' | 'csv-import'
isDuplicating?: boolean
closePanel: () => void
onRowCreated?: (row: Dictionary<any>) => void
onRowUpdated?: (row: Dictionary<any>, idx: number) => void
@@ -51,19 +40,12 @@ export interface SidePanelEditorProps {
const SidePanelEditor = ({
selectedTable,
selectedRowToEdit,
selectedColumnToEdit,
selectedTableToEdit,
selectedValueForJsonEdit,
selectedForeignKeyToEdit,
sidePanelKey,
isDuplicating = false,
closePanel,
onRowCreated = noop,
onRowUpdated = noop,
onTableCreated = noop,
onColumnSaved = noop,
}: SidePanelEditorProps) => {
const snap = useTableEditorStateSnapshot()
const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
const { meta, ui } = useStore()
const queryClient = useQueryClient()
@@ -82,7 +64,7 @@ const SidePanelEditor = ({
const { mutateAsync: createTableRows } = useTableRowCreateMutation()
const { mutateAsync: updateTableRow } = useTableRowUpdateMutation({
async onMutate({ projectRef, table, configuration, payload }) {
closePanel()
snap.closeSidePanel()
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
@@ -199,12 +181,13 @@ const SidePanelEditor = ({
onComplete()
if (!saveRowError) {
setIsEdited(false)
closePanel()
snap.closeSidePanel()
}
}
const onSaveJSON = async (value: string | number) => {
if (selectedTable === undefined || selectedValueForJsonEdit === undefined) return
if (selectedTable === undefined || !(snap.sidePanel?.type === 'json')) return
const selectedValueForJsonEdit = snap.sidePanel.jsonValue
try {
const { row, column } = selectedValueForJsonEdit
@@ -220,7 +203,8 @@ const SidePanelEditor = ({
}
const onSaveForeignRow = async (value: any) => {
if (selectedTable === undefined || selectedForeignKeyToEdit === undefined) return
if (selectedTable === undefined || !(snap.sidePanel?.type === 'foreign-row-selector')) return
const selectedForeignKeyToEdit = snap.sidePanel.foreignKey
try {
const { row, column } = selectedForeignKeyToEdit
@@ -242,6 +226,8 @@ const SidePanelEditor = ({
configuration: { columnId?: string; isEncrypted: boolean; keyId?: string; keyName?: string },
resolve: any
) => {
const selectedColumnToEdit = snap.sidePanel?.type === 'column' && snap.sidePanel.column
const { columnId, ...securityConfig } = configuration
const response = isNewRecord
? await meta.createColumn(
@@ -285,7 +271,7 @@ const SidePanelEditor = ({
])
onColumnSaved(configuration.isEncrypted)
setIsEdited(false)
closePanel()
snap.closeSidePanel()
}
if (configuration.isEncrypted && selectedTable?.schema) {
@@ -336,8 +322,12 @@ const SidePanelEditor = ({
configuration
try {
if (isDuplicating) {
const duplicateTable = find(tables, { id: tableId }) as PostgresTable
if (
snap.sidePanel?.type === 'table' &&
snap.sidePanel.mode === 'duplicate' &&
selectedTable
) {
const duplicateTable = selectedTable
toastId = ui.setNotification({
category: 'loading',
@@ -384,15 +374,15 @@ const SidePanelEditor = ({
})
onTableCreated(table)
} else if (selectedTableToEdit) {
} else if (selectedTable) {
toastId = ui.setNotification({
category: 'loading',
message: `Updating table: ${selectedTableToEdit?.name}...`,
message: `Updating table: ${selectedTable?.name}...`,
})
const { table, hasError }: any = await meta.updateTable(
toastId,
selectedTableToEdit,
selectedTable,
payload,
columns,
isRealtimeEnabled
@@ -408,13 +398,13 @@ const SidePanelEditor = ({
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
await Promise.all([
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [selectedTableToEdit.schema, selectedTableToEdit.name])
sqlKeys.query(project?.ref, [selectedTable.schema, selectedTable.name])
),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [
'table-definition',
selectedTableToEdit.schema,
selectedTableToEdit.name,
selectedTable.schema,
selectedTable.name,
])
),
queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)),
@@ -434,7 +424,7 @@ const SidePanelEditor = ({
if (!saveTableError) {
setIsEdited(false)
closePanel()
snap.closeSidePanel()
}
resolve()
@@ -514,14 +504,14 @@ const SidePanelEditor = ({
message: `Successfully imported ${rowCount} rows of data into ${selectedTable.name}`,
})
resolve()
closePanel()
snap.closeSidePanel()
}
const onClosePanel = () => {
if (isEdited) {
setIsClosingPanel(true)
} else {
closePanel()
snap.closeSidePanel()
}
}
@@ -529,9 +519,9 @@ const SidePanelEditor = ({
<>
{!isUndefined(selectedTable) && (
<RowEditor
row={selectedRowToEdit}
row={snap.sidePanel?.type === 'row' ? snap.sidePanel.row : undefined}
selectedTable={selectedTable}
visible={sidePanelKey === 'row'}
visible={snap.sidePanel?.type === 'row'}
closePanel={onClosePanel}
saveChanges={saveRow}
updateEditorDirty={() => setIsEdited(true)}
@@ -539,41 +529,58 @@ const SidePanelEditor = ({
)}
{!isUndefined(selectedTable) && (
<ColumnEditor
column={selectedColumnToEdit}
column={
snap.sidePanel?.type === 'column'
? (snap.sidePanel.column as PostgresColumn)
: undefined
}
selectedTable={selectedTable}
visible={sidePanelKey === 'column'}
visible={snap.sidePanel?.type === 'column'}
closePanel={onClosePanel}
saveChanges={saveColumn}
updateEditorDirty={() => setIsEdited(true)}
/>
)}
<TableEditor
table={selectedTableToEdit}
isDuplicating={isDuplicating}
visible={sidePanelKey === 'table'}
table={
snap.sidePanel?.type === 'table' &&
(snap.sidePanel.mode === 'edit' || snap.sidePanel.mode === 'duplicate')
? selectedTable
: undefined
}
isDuplicating={snap.sidePanel?.type === 'table' && snap.sidePanel.mode === 'duplicate'}
visible={snap.sidePanel?.type === 'table'}
closePanel={onClosePanel}
saveChanges={saveTable}
updateEditorDirty={() => setIsEdited(true)}
/>
<JsonEdit
visible={sidePanelKey === 'json'}
column={selectedValueForJsonEdit?.column ?? ''}
jsonString={selectedValueForJsonEdit?.jsonString ?? ''}
visible={snap.sidePanel?.type === 'json'}
column={(snap.sidePanel?.type === 'json' && snap.sidePanel.jsonValue.column) || ''}
jsonString={(snap.sidePanel?.type === 'json' && snap.sidePanel.jsonValue.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}
key={`foreign-row-selector-${
(snap.sidePanel?.type === 'foreign-row-selector' &&
snap.sidePanel.foreignKey.foreignKey.id) ||
'null'
}`}
visible={snap.sidePanel?.type === 'foreign-row-selector'}
foreignKey={
snap.sidePanel?.type === 'foreign-row-selector'
? snap.sidePanel.foreignKey.foreignKey
: undefined
}
closePanel={onClosePanel}
onSelect={onSaveForeignRow}
/>
<SpreadsheetImport
visible={sidePanelKey === 'csv-import'}
selectedTable={selectedTableToEdit}
visible={snap.sidePanel?.type === 'csv-import'}
selectedTable={selectedTable}
saveContent={onImportData}
closePanel={onClosePanel}
updateEditorDirty={setIsEdited}
@@ -586,7 +593,7 @@ const SidePanelEditor = ({
onSelectConfirm={() => {
setIsClosingPanel(false)
setIsEdited(false)
closePanel()
snap.closeSidePanel()
}}
>
<Modal.Content>
@@ -1,7 +1,7 @@
import { useRef, useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { find, isUndefined, noop } from 'lodash'
import { find, isUndefined } from 'lodash'
import type { PostgresColumn, PostgresRelationship, PostgresTable } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { QueryKey, useQueryClient } from '@tanstack/react-query'
@@ -34,70 +34,23 @@ import { useTableEditorStateSnapshot } from 'state/table-editor'
import TwoOptionToggle from 'components/ui/TwoOptionToggle'
import { ERROR_PRIMARY_KEY_NOTFOUND } from 'components/grid/constants'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { JsonEditValue } from './SidePanelEditor/RowEditor/RowEditor.types'
import { ForeignRowSelectorProps } from './SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
import TableDefinition from './TableDefinition'
import APIDocumentationPanel from './APIDocumentationPanel'
import Connecting from 'components/ui/Loading/Loading'
import { TableLike } from 'hooks/misc/useTable'
export interface TableGridEditorProps {
/** Theme for the editor */
theme?: 'dark' | 'light'
isLoadingSelectedTable?: boolean
selectedTable: any // PostgresTable | SchemaView
/** Determines what side panel editor to show */
sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' | 'csv-import'
/** Toggles if we're duplicating a table */
isDuplicating: boolean
/** Selected entities if we're editing a row, column or table */
selectedRowToEdit?: Dictionary<any>
selectedColumnToEdit?: PostgresColumn
selectedTableToEdit?: PostgresTable
selectedValueForJsonEdit?: JsonEditValue
selectedForeignKeyToEdit?: {
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
row: any
column: any
}
onAddRow: () => void
onEditRow: (row: Dictionary<any>) => void
onAddColumn: () => void
onEditColumn: (column: PostgresColumn) => void
onDeleteColumn: (column: PostgresColumn) => void
onExpandJSONEditor: (column: string, row: any) => void
onEditForeignKeyColumnValue: (args: {
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
row: any
column: any
}) => void
onClosePanel: () => void
onImportData: () => void
selectedTable?: TableLike
}
const TableGridEditor = ({
theme = 'dark',
isLoadingSelectedTable = false,
selectedTable,
sidePanelKey,
isDuplicating,
selectedRowToEdit,
selectedColumnToEdit,
selectedTableToEdit,
selectedValueForJsonEdit,
selectedForeignKeyToEdit,
onAddRow = noop,
onEditRow = noop,
onAddColumn = noop,
onEditColumn = noop,
onDeleteColumn = noop,
onExpandJSONEditor = noop,
onEditForeignKeyColumnValue = noop,
onClosePanel = noop,
onImportData = noop,
}: TableGridEditorProps) => {
const { project } = useProjectContext()
const snap = useTableEditorStateSnapshot()
@@ -214,8 +167,6 @@ const TableGridEditor = ({
return <NotFoundState id={Number(id)} />
}
const tableId = selectedTable?.id
const isViewSelected =
entityType?.type === ENTITY_TYPE.VIEW || entityType?.type === ENTITY_TYPE.MATERIALIZED_VIEW
const isTableSelected = entityType?.type === ENTITY_TYPE.TABLE
@@ -226,17 +177,18 @@ const TableGridEditor = ({
// [Joshen] We can tweak below to eventually support composite keys as the data
// returned from foreignKeyMeta should be easy to deal with, rather than pg-meta
const formattedRelationships = (selectedTable?.relationships ?? []).map(
(relationship: PostgresRelationship) => {
const relationshipMeta = foreignKeyMeta.find(
(fk: ForeignKeyConstraint) => fk.id === relationship.id
)
return {
...relationship,
deletion_action: relationshipMeta?.deletion_action ?? FOREIGN_KEY_DELETION_ACTION.NO_ACTION,
}
const formattedRelationships = (
('relationships' in selectedTable && selectedTable.relationships) ||
[]
).map((relationship: PostgresRelationship) => {
const relationshipMeta = foreignKeyMeta.find(
(fk: ForeignKeyConstraint) => fk.id === relationship.id
)
return {
...relationship,
deletion_action: relationshipMeta?.deletion_action ?? FOREIGN_KEY_DELETION_ACTION.NO_ACTION,
}
)
})
const gridTable =
!isViewSelected && !isForeignTableSelected
@@ -284,26 +236,21 @@ const TableGridEditor = ({
}
const onSelectEditColumn = async (name: string) => {
// For some reason, selectedTable here is stale after adding a table
// temporary workaround is to list grab the selected table again
const tables: PostgresTable[] = meta.tables.list()
// @ts-ignore
const table = tables.find((table) => table.id === Number(tableId))
const column = find(table!.columns, { name }) as PostgresColumn
const column = find(selectedTable?.columns ?? [], { name }) as PostgresColumn
if (column) {
onEditColumn(column)
snap.onEditColumn(column)
} else {
console.error(`Unable to find column ${name} in ${table?.name}`)
console.error(`Unable to find column ${name} in ${selectedTable?.name}`)
}
}
const onSelectDeleteColumn = async (name: string) => {
// For some reason, selectedTable here is stale after adding a table
// temporary workaround is to list grab the selected table again
const tables: PostgresTable[] = meta.tables.list()
const table = tables.find((table) => table.id === Number(tableId))
const column = find(table!.columns, { name }) as PostgresColumn
onDeleteColumn(column)
const column = find(selectedTable?.columns ?? [], { name }) as PostgresColumn
if (column) {
snap.onDeleteColumn(column)
} else {
console.error(`Unable to find column ${name} in ${selectedTable?.name}`)
}
}
const onError = (error: any) => {
@@ -316,11 +263,14 @@ const TableGridEditor = ({
const updateTableRow = (previousRow: any, updatedData: any) => {
if (!project) return
const enumArrayColumns = selectedTable.columns
.filter((column: any) => {
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
})
.map((column: any) => column.name)
const enumArrayColumns =
('columns' in selectedTable &&
selectedTable.columns
?.filter((column) => {
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
})
.map((column) => column.name)) ||
[]
const identifiers = {} as Dictionary<any>
;(selectedTable as PostgresTable).primary_keys.forEach(
@@ -392,17 +342,19 @@ const TableGridEditor = ({
</>
) : null
}
onAddColumn={onAddColumn}
onAddColumn={snap.onAddColumn}
onEditColumn={onSelectEditColumn}
onDeleteColumn={onSelectDeleteColumn}
onAddRow={onAddRow}
onAddRow={snap.onAddRow}
updateTableRow={updateTableRow}
onEditRow={onEditRow}
onImportData={onImportData}
onEditRow={snap.onEditRow}
onImportData={snap.onImportData}
onError={onError}
onSqlQuery={onSqlQuery}
onExpandJSONEditor={onExpandJSONEditor}
onEditForeignKeyColumnValue={onEditForeignKeyColumnValue}
onExpandJSONEditor={(column, row) =>
snap.onExpandJSONEditor({ column, row, jsonString: JSON.stringify(row[column]) || '' })
}
onEditForeignKeyColumnValue={snap.onEditForeignKeyColumnValue}
showCustomChildren={(isViewSelected || isTableSelected) && selectedView === 'definition'}
customHeader={
(isViewSelected || isTableSelected) && selectedView === 'definition' ? (
@@ -420,19 +372,11 @@ const TableGridEditor = ({
{!isUndefined(snap.selectedSchemaName) && (
<SidePanelEditor
isDuplicating={isDuplicating}
selectedTable={selectedTable as PostgresTable}
selectedRowToEdit={selectedRowToEdit}
selectedColumnToEdit={selectedColumnToEdit}
selectedTableToEdit={selectedTableToEdit}
selectedValueForJsonEdit={selectedValueForJsonEdit}
selectedForeignKeyToEdit={selectedForeignKeyToEdit}
sidePanelKey={sidePanelKey}
onRowCreated={onRowCreated}
onRowUpdated={onRowUpdated}
onColumnSaved={onColumnSaved}
onTableCreated={onTableCreated}
closePanel={onClosePanel}
/>
)}
@@ -6,28 +6,17 @@ import { Entity } from 'data/entity-types/entity-type-query'
import Link from 'next/link'
import { Dropdown, IconEdit, IconCopy, IconLock, IconTrash, IconChevronDown } from 'ui'
import { BASE_PATH } from 'lib/constants'
import { useTableEditorStateSnapshot } from 'state/table-editor'
export interface EntityListItemProps {
id: number
projectRef: string
item: Entity
isLocked: boolean
onEditTable: (table: Entity) => void
onDeleteTable: (table: Entity) => void
onDuplicateTable: (table: Entity) => void
isLoadingTableMetadata?: boolean
}
const EntityListItem = ({
id,
projectRef,
item: entity,
isLocked,
onEditTable,
onDeleteTable,
onDuplicateTable,
isLoadingTableMetadata,
}: EntityListItemProps) => {
const EntityListItem = ({ id, projectRef, item: entity, isLocked }: EntityListItemProps) => {
const snap = useTableEditorStateSnapshot()
const isActive = Number(id) === entity.id
const formatTooltipText = (entityType: string) => {
return Object.entries(ENTITY_TYPE)
@@ -137,9 +126,8 @@ const EntityListItem = ({
icon={<IconEdit size="tiny" />}
onClick={(e) => {
e.stopPropagation()
onEditTable(entity)
snap.onEditTable()
}}
disabled={isLoadingTableMetadata}
>
Edit Table
</Dropdown.Item>,
@@ -148,9 +136,8 @@ const EntityListItem = ({
icon={<IconCopy size="tiny" />}
onClick={(e) => {
e.stopPropagation()
onDuplicateTable(entity)
snap.onDuplicateTable()
}}
disabled={isLoadingTableMetadata}
>
Duplicate Table
</Dropdown.Item>,
@@ -170,9 +157,8 @@ const EntityListItem = ({
icon={<IconTrash size="tiny" />}
onClick={(e) => {
e.stopPropagation()
onDeleteTable(entity)
snap.onDeleteTable()
}}
disabled={isLoadingTableMetadata}
>
Delete Table
</Dropdown.Item>,
@@ -1,28 +1,15 @@
import { PropsWithChildren, useEffect } from 'react'
import { noop } from 'lodash'
import { observer } from 'mobx-react-lite'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useStore } from 'hooks'
import { Entity } from 'data/entity-types/entity-type-query'
import ProjectLayout from '../'
import { ProjectLayoutWithAuth } from '../'
import TableEditorMenu from './TableEditorMenu'
import NoPermission from 'components/ui/NoPermission'
export interface TableEditorLayoutProps {
onAddTable: () => void
onEditTable: (table: Entity) => void
onDeleteTable: (table: Entity) => void
onDuplicateTable: (table: Entity) => void
}
export interface TableEditorLayoutProps {}
const TableEditorLayout = ({
onAddTable = noop,
onEditTable = noop,
onDeleteTable = noop,
onDuplicateTable = noop,
children,
}: PropsWithChildren<TableEditorLayoutProps>) => {
const TableEditorLayout = ({ children }: PropsWithChildren<TableEditorLayoutProps>) => {
const { vault, meta, ui } = useStore()
const canReadTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables')
@@ -47,26 +34,16 @@ const TableEditorLayout = ({
if (!canReadTables) {
return (
<ProjectLayout>
<ProjectLayoutWithAuth>
<NoPermission isFullPage resourceText="view tables from this project" />
</ProjectLayout>
</ProjectLayoutWithAuth>
)
}
return (
<ProjectLayout
product="Table editor"
productMenu={
<TableEditorMenu
onAddTable={onAddTable}
onEditTable={onEditTable}
onDeleteTable={onDeleteTable}
onDuplicateTable={onDuplicateTable}
/>
}
>
<ProjectLayoutWithAuth product="Table editor" productMenu={<TableEditorMenu />}>
{children}
</ProjectLayout>
</ProjectLayoutWithAuth>
)
}
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react'
import { noop, partition } from 'lodash'
import { observer } from 'mobx-react-lite'
import { partition } from 'lodash'
import {
Button,
Dropdown,
@@ -20,29 +19,17 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common/hooks'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { checkPermissions, useLocalStorage, useStore } from 'hooks'
import { checkPermissions, useLocalStorage } from 'hooks'
import InfiniteList from 'components/ui/InfiniteList'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
import { Entity } from 'data/entity-types/entity-type-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import EntityListItem from './EntityListItem'
export interface TableEditorMenuProps {
onAddTable: () => void
onEditTable: (table: Entity) => void
onDeleteTable: (table: Entity) => void
onDuplicateTable: (table: Entity) => void
}
export interface TableEditorMenuProps {}
const TableEditorMenu = ({
onAddTable = noop,
onEditTable = noop,
onDeleteTable = noop,
onDuplicateTable = noop,
}: TableEditorMenuProps) => {
const { meta } = useStore()
const TableEditorMenu = ({}: TableEditorMenuProps) => {
const { id } = useParams()
const snap = useTableEditorStateSnapshot()
@@ -93,8 +80,6 @@ const TableEditorMenu = ({
const schema = schemas?.find((schema) => schema.name === snap.selectedSchemaName)
const canCreateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const isLoadingTableMetadata = id ? !meta.tables.byId(id) : true
const refreshTables = async () => {
await refetch()
}
@@ -189,7 +174,7 @@ const TableEditorMenu = ({
}
type="default"
style={{ justifyContent: 'start' }}
onClick={onAddTable}
onClick={snap.onAddTable}
>
New table
</Button>
@@ -353,10 +338,6 @@ const TableEditorMenu = ({
itemProps={{
projectRef: project?.ref,
id: Number(id),
onEditTable,
onDeleteTable,
onDuplicateTable,
isLoadingTableMetadata,
}}
getItemSize={() => 28}
hasNextPage={hasNextPage}
@@ -370,4 +351,4 @@ const TableEditorMenu = ({
)
}
export default observer(TableEditorMenu)
export default TableEditorMenu
+9 -4
View File
@@ -1,11 +1,16 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { useForeignTableQuery } from 'data/foreign-tables/foreign-table-query'
import { useMaterializedViewQuery } from 'data/materialized-views/materialized-view-query'
import { useTableQuery } from 'data/tables/table-query'
import { useViewQuery } from 'data/views/view-query'
import { ForeignTable, useForeignTableQuery } from 'data/foreign-tables/foreign-table-query'
import {
MaterializedView,
useMaterializedViewQuery,
} from 'data/materialized-views/materialized-view-query'
import { Table, useTableQuery } from 'data/tables/table-query'
import { View, useViewQuery } from 'data/views/view-query'
import useEntityType from './useEntityType'
export type TableLike = Table | View | MaterializedView | ForeignTable
/**
* A hook that loads all table-like objects. e.g. tables, views, materialized views, etc...
*/
+53 -177
View File
@@ -1,206 +1,82 @@
import { useQueryClient } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { observer } from 'mobx-react-lite'
import { isUndefined } from 'lodash'
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
import { Modal } from 'ui'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useStore } from 'hooks'
import { sqlKeys } from 'data/sql/keys'
import { DatabaseLayout } from 'components/layouts'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { TableList, ColumnList } from 'components/interfaces/Database'
import { useParams } from 'common'
import { ColumnList, TableList } from 'components/interfaces/Database'
import { SidePanelEditor } from 'components/interfaces/TableGridEditor'
import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
import { DatabaseLayout } from 'components/layouts'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { Table, useTableQuery } from 'data/tables/table-query'
import { useStore } from 'hooks'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { NextPageWithLayout } from 'types'
const DatabaseTables: NextPageWithLayout = () => {
const { meta, ui } = useStore()
const { project } = useProjectContext()
const snap = useTableEditorStateSnapshot()
const { meta } = useStore()
const queryClient = useQueryClient()
const router = useRouter()
const { id: _id, ref: projectRef } = useParams()
const id = _id ? Number(_id) : undefined
const [isDeleting, setIsDeleting] = useState<boolean>(false)
const [selectedTable, setSelectedTable] = useState<any>()
const [sidePanelKey, setSidePanelKey] = useState<'column' | 'table'>()
const [selectedColumnToEdit, setSelectedColumnToEdit] = useState<PostgresColumn>()
const [selectedTableToEdit, setSelectedTableToEdit] = useState<PostgresTable>()
const [selectedColumnToDelete, setSelectedColumnToDelete] = useState<PostgresColumn>()
const [selectedTableToDelete, setSelectedTableToDelete] = useState<PostgresTable>()
const { data: selectedTable } = useTableQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
id,
})
useEffect(() => {
if (ui.selectedProject?.ref) {
if (project?.ref) {
meta.types.load()
}
}, [ui.selectedProject?.ref])
}, [project?.ref])
const onAddTable = () => {
setSidePanelKey('table')
setSelectedTableToEdit(undefined)
}
const onEditTable = (table: PostgresTable) => {
setSidePanelKey('table')
setSelectedTableToEdit(table)
}
const onDeleteTable = (table: PostgresTable) => {
setIsDeleting(true)
setSelectedTableToDelete(table)
}
const onAddColumn = () => {
setSidePanelKey('column')
setSelectedColumnToEdit(undefined)
}
const onEditColumn = (column: PostgresColumn) => {
setSidePanelKey('column')
setSelectedColumnToEdit(column)
}
const onDeleteColumn = (column: PostgresColumn) => {
setIsDeleting(true)
setSelectedColumnToDelete(column)
}
const onColumnUpdated = async () => {
if (selectedTable === undefined) return
const updatedTable = await meta.tables.loadById(selectedTable.id)
setSelectedTable(updatedTable)
}
const onClosePanel = () => setSidePanelKey(undefined)
const onConfirmDeleteTable = async () => {
try {
if (isUndefined(selectedTableToDelete)) return
const response: any = await meta.tables.del(selectedTableToDelete.id)
if (response.error) {
throw response.error
} else {
ui.setNotification({
category: 'success',
message: `Successfully removed ${selectedTableToDelete.name}.`,
})
}
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to delete ${selectedTableToDelete?.name}: ${error.message}`,
})
} finally {
setIsDeleting(false)
setSelectedTableToDelete(undefined)
}
}
const onConfirmDeleteColumn = async () => {
try {
if (isUndefined(selectedColumnToDelete)) return
const response: any = await meta.columns.del(selectedColumnToDelete.id)
if (response.error) {
throw response.error
} else {
onColumnUpdated()
ui.setNotification({
category: 'success',
message: `Successfully removed ${selectedColumnToDelete.name}.`,
})
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
await Promise.all([
meta.tables.loadById(selectedColumnToDelete!.table_id),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name])
),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [
'table-definition',
selectedTable!.schema,
selectedTable!.name,
])
),
])
}
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to delete ${selectedColumnToDelete?.name}: ${error.message}`,
})
} finally {
setIsDeleting(false)
setSelectedColumnToDelete(undefined)
}
const setSelectedTable = (table: Table | undefined) => {
router.replace({
pathname: router.pathname,
query: table
? {
...router.query,
id: table.id,
}
: {
...router.query,
id: undefined, // TODO(alaister): needs full removal of ?id= from the url
},
})
}
return (
<>
<div className="p-4">
{isUndefined(selectedTable) ? (
<TableList
onAddTable={onAddTable}
onEditTable={onEditTable}
onDeleteTable={onDeleteTable}
onOpenTable={setSelectedTable}
/>
) : (
{id !== undefined && selectedTable !== undefined ? (
<ColumnList
selectedTable={selectedTable}
onAddColumn={onAddColumn}
onEditColumn={onEditColumn}
onDeleteColumn={onDeleteColumn}
onAddColumn={snap.onAddColumn}
onEditColumn={snap.onEditColumn}
onDeleteColumn={snap.onDeleteColumn}
onSelectBack={() => setSelectedTable(undefined)}
/>
) : (
<TableList
onAddTable={snap.onAddTable}
onEditTable={(table) => {
setSelectedTable(table)
snap.onEditTable()
}}
onDeleteTable={(table) => {
setSelectedTable(table)
snap.onDeleteTable()
}}
onOpenTable={setSelectedTable}
/>
)}
</div>
<ConfirmationModal
danger
visible={isDeleting && !isUndefined(selectedTableToDelete)}
header={
<span className="break-words">{`Confirm deletion of table "${selectedTableToDelete?.name}"`}</span>
}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => setIsDeleting(false)}
onSelectConfirm={onConfirmDeleteTable}
>
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
Are you sure you want to delete the selected table? This action cannot be undone.
</p>
</Modal.Content>
</ConfirmationModal>
<ConfirmationModal
danger
visible={isDeleting && !isUndefined(selectedColumnToDelete)}
header={`Confirm deletion of column "${selectedColumnToDelete?.name}"`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => setIsDeleting(false)}
onSelectConfirm={onConfirmDeleteColumn}
>
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
Are you sure you want to delete the selected column? This action cannot be undone.
</p>
</Modal.Content>
</ConfirmationModal>
<SidePanelEditor
sidePanelKey={sidePanelKey}
selectedTable={selectedTable}
onColumnSaved={onColumnUpdated}
closePanel={onClosePanel}
selectedColumnToEdit={selectedColumnToEdit}
selectedTableToEdit={selectedTableToEdit}
/>
<DeleteConfirmationDialogs projectRef={projectRef} selectedTable={selectedTable} />
<SidePanelEditor selectedTable={selectedTable} />
</>
)
}
+13 -362
View File
@@ -1,384 +1,35 @@
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { isUndefined } from 'lodash'
import { Alert, Button, Checkbox, IconExternalLink, Modal } from 'ui'
import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta'
import { useStore, withAuth, useUrlState } from 'hooks'
import { useParams } from 'common/hooks'
import { entityTypeKeys } from 'data/entity-types/keys'
import { Entity } from 'data/entity-types/entity-type-query'
import { sqlKeys } from 'data/sql/keys'
import { Dictionary } from 'components/grid'
import { TableEditorLayout } from 'components/layouts'
import { TableGridEditor } from 'components/interfaces'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { NextPageWithLayout, SchemaView } from 'types'
import { JsonEditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types'
import {
ProjectContextFromParamsProvider,
useProjectContext,
} from 'components/layouts/ProjectLayout/ProjectContext'
import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
import { useTheme } from 'common'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useParams } from 'common/hooks'
import { TableGridEditor } from 'components/interfaces'
import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
import { TableEditorLayout } from 'components/layouts'
import { ProjectContextFromParamsProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import useTable from 'hooks/misc/useTable'
import { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const router = useRouter()
const { isDarkMode } = useTheme()
const { id: _id, ref: projectRef } = useParams()
const id = _id ? Number(_id) : undefined
const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
const { project } = useProjectContext()
const snap = useTableEditorStateSnapshot()
const queryClient = useQueryClient()
const { meta, ui } = useStore()
const [isDeleting, setIsDeleting] = useState<boolean>(false)
const [isDuplicating, setIsDuplicating] = useState<boolean>(false)
const [isDeleteWithCascade, setIsDeleteWithCascade] = useState(false)
const [selectedColumnToDelete, setSelectedColumnToDelete] = useState<PostgresColumn>()
const [selectedTableToDelete, setSelectedTableToDelete] = useState<PostgresTable>()
const [sidePanelKey, setSidePanelKey] = useState<
'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' | 'csv-import'
>()
const [selectedRowToEdit, setSelectedRowToEdit] = useState<Dictionary<any>>()
const [selectedColumnToEdit, setSelectedColumnToEdit] = useState<PostgresColumn>()
const [selectedTableToEdit, setSelectedTableToEdit] = useState<PostgresTable>()
const [selectedValueForJsonEdit, setSelectedValueForJsonEdit] = useState<JsonEditValue>()
const [selectedForeignKeyToEdit, setSelectedForeignKeyToEdit] = useState<{
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
row: any
column: any
}>()
const { data: selectedTable, isLoading } = useTable(id)
const onAddRow = () => {
setSidePanelKey('row')
setSelectedRowToEdit(undefined)
}
const onEditRow = (row: Dictionary<any>) => {
setSidePanelKey('row')
setSelectedRowToEdit(row)
}
const onAddColumn = () => {
setSidePanelKey('column')
setSelectedColumnToEdit(undefined)
}
const onEditColumn = (column: PostgresColumn) => {
setSidePanelKey('column')
setSelectedColumnToEdit(column)
}
const onDeleteColumn = (column: PostgresColumn) => {
setIsDeleting(true)
setSelectedColumnToDelete(column)
setIsDeleteWithCascade(false)
}
const onAddTable = () => {
setSidePanelKey('table')
setIsDuplicating(false)
setSelectedTableToEdit(undefined)
}
const onEditTable = (entity: Entity) => {
setSidePanelKey('table')
setIsDuplicating(false)
const table = meta.tables.byId(entity.id)
setSelectedTableToEdit(table)
}
const onDeleteTable = (entity: Entity) => {
setIsDeleting(true)
const table = meta.tables.byId(entity.id)
setSelectedTableToDelete(table)
setIsDeleteWithCascade(false)
}
const onDuplicateTable = (entity: Entity) => {
setSidePanelKey('table')
setIsDuplicating(true)
const table = meta.tables.byId(entity.id)
setSelectedTableToEdit(table)
}
const onExpandJSONEditor = (column: string, row: any) => {
setSidePanelKey('json')
setSelectedValueForJsonEdit({ column, row, jsonString: JSON.stringify(row[column]) || '' })
}
const onImportData = () => {
if (id) {
setSidePanelKey('csv-import')
const table = meta.tables.byId(id)
setSelectedTableToEdit(table)
} else {
console.error('Table ID not found')
}
}
const onEditForeignKeyColumnValue = ({
foreignKey,
row,
column,
}: {
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
row: any
column: any
}) => {
setSidePanelKey('foreign-row-selector')
setSelectedForeignKeyToEdit({
foreignKey,
row,
column,
})
}
const onClosePanel = () => {
setSidePanelKey(undefined)
}
const removeDeletedColumnFromFiltersAndSorts = (columnName: string) => {
setParams((prevParams) => {
const existingFilters = (prevParams?.filter ?? []) as string[]
const existingSorts = (prevParams?.sort ?? []) as string[]
return {
...prevParams,
filter: existingFilters.filter((filter: string) => {
const [column] = filter.split(':')
if (column !== columnName) return filter
}),
sort: existingSorts.filter((sort: string) => {
const [column] = sort.split(':')
if (column !== columnName) return sort
}),
}
})
}
const onConfirmDeleteColumn = async () => {
try {
if (selectedColumnToDelete === undefined) return
const response: any = await meta.columns.del(selectedColumnToDelete.id, isDeleteWithCascade)
if (response.error) throw response.error
removeDeletedColumnFromFiltersAndSorts(selectedColumnToDelete.name)
ui.setNotification({
category: 'success',
message: `Successfully deleted column "${selectedColumnToDelete.name}"`,
})
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
await Promise.all([
meta.tables.loadById(selectedColumnToDelete!.table_id),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name])
),
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [
'table-definition',
selectedTable!.schema,
selectedTable!.name,
])
),
])
if (snap.selectedSchemaName) await meta.views.loadBySchema(snap.selectedSchemaName)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to delete ${selectedColumnToDelete!.name}: ${error.message}`,
})
} finally {
setIsDeleting(false)
setSelectedColumnToDelete(undefined)
}
}
const onConfirmDeleteTable = async () => {
try {
if (selectedTableToDelete === undefined) return
const response: any = await meta.tables.del(selectedTableToDelete.id, isDeleteWithCascade)
if (response.error) throw response.error
const tables = meta.tables.list(
(table: PostgresTable) => table.schema === snap.selectedSchemaName
)
await queryClient.invalidateQueries(entityTypeKeys.list(projectRef))
// For simplicity for now, we just open the first table within the same schema
if (tables.length > 0) {
router.push(`/project/${projectRef}/editor/${tables[0].id}`)
} else {
router.push(`/project/${projectRef}/editor/`)
}
ui.setNotification({
category: 'success',
message: `Successfully deleted table "${selectedTableToDelete.name}"`,
})
if (snap.selectedSchemaName) await meta.views.loadBySchema(snap.selectedSchemaName)
} catch (error: any) {
ui.setNotification({
error,
category: 'error',
message: `Failed to delete ${selectedTableToDelete?.name}: ${error.message}`,
})
} finally {
setIsDeleting(false)
setSelectedTableToDelete(undefined)
}
}
return (
<TableEditorLayout
onAddTable={onAddTable}
onEditTable={onEditTable}
onDeleteTable={onDeleteTable}
onDuplicateTable={onDuplicateTable}
>
<>
<TableGridEditor
isLoadingSelectedTable={isLoading}
selectedTable={selectedTable}
sidePanelKey={sidePanelKey}
isDuplicating={isDuplicating}
selectedRowToEdit={selectedRowToEdit}
selectedColumnToEdit={selectedColumnToEdit}
selectedTableToEdit={selectedTableToEdit}
selectedValueForJsonEdit={selectedValueForJsonEdit}
selectedForeignKeyToEdit={selectedForeignKeyToEdit}
onAddRow={onAddRow}
onEditRow={onEditRow}
onAddColumn={onAddColumn}
onEditColumn={onEditColumn}
onDeleteColumn={onDeleteColumn}
onExpandJSONEditor={onExpandJSONEditor}
onEditForeignKeyColumnValue={onEditForeignKeyColumnValue}
onClosePanel={onClosePanel}
onImportData={onImportData}
theme={isDarkMode ? 'dark' : 'light'}
/>
<ConfirmationModal
danger
size="small"
visible={isDeleting && !isUndefined(selectedColumnToDelete)}
header={`Confirm deletion of column "${selectedColumnToDelete?.name}"`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => {
setIsDeleting(false)
setSelectedColumnToDelete(undefined)
}}
onSelectConfirm={onConfirmDeleteColumn}
>
<Modal.Content>
<div className="py-4 space-y-4">
<p className="text-sm text-scale-1100">
Are you sure you want to delete the selected column? This action cannot be undone.
</p>
<Checkbox
label="Drop column with cascade?"
description="Deletes the column and its dependent objects"
checked={isDeleteWithCascade}
onChange={() => setIsDeleteWithCascade(!isDeleteWithCascade)}
/>
{isDeleteWithCascade && (
<Alert
withIcon
variant="warning"
title="Warning: Dropping with cascade may result in unintended consequences"
>
<p className="mb-4">
All dependent objects will be removed, as will any objects that depend on them,
recursively.
</p>
<Link href="https://www.postgresql.org/docs/current/ddl-depend.html">
<a target="_blank" rel="noreferrer">
<Button size="tiny" type="default" icon={<IconExternalLink />}>
About dependency tracking
</Button>
</a>
</Link>
</Alert>
)}
</div>
</Modal.Content>
</ConfirmationModal>
<ConfirmationModal
danger
size="small"
visible={isDeleting && !isUndefined(selectedTableToDelete)}
header={
<span className="break-words">{`Confirm deletion of table "${selectedTableToDelete?.name}"`}</span>
}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => {
setIsDeleting(false)
setSelectedTableToDelete(undefined)
}}
onSelectConfirm={onConfirmDeleteTable}
>
<Modal.Content>
<div className="py-4 space-y-4">
<p className="text-sm text-scale-1100">
Are you sure you want to delete the selected table? This action cannot be undone.
</p>
<Checkbox
label="Drop table with cascade?"
description="Deletes the table and its dependent objects"
checked={isDeleteWithCascade}
onChange={() => setIsDeleteWithCascade(!isDeleteWithCascade)}
/>
{isDeleteWithCascade && (
<Alert
withIcon
variant="warning"
title="Warning: Dropping with cascade may result in unintended consequences"
>
<p className="mb-4">
All dependent objects will be removed, as will any objects that depend on them,
recursively.
</p>
<Link href="https://www.postgresql.org/docs/current/ddl-depend.html">
<a target="_blank" rel="noreferrer">
<Button size="tiny" type="default" icon={<IconExternalLink />}>
About dependency tracking
</Button>
</a>
</Link>
</Alert>
)}
</div>
</Modal.Content>
</ConfirmationModal>
</TableEditorLayout>
<DeleteConfirmationDialogs projectRef={projectRef} selectedTable={selectedTable} />
</>
)
}
TableEditorPage.getLayout = (page) => (
<ProjectContextFromParamsProvider>{page}</ProjectContextFromParamsProvider>
<ProjectContextFromParamsProvider>
<TableEditorLayout>{page}</TableEditorLayout>
</ProjectContextFromParamsProvider>
)
export default withAuth(observer(TableEditorPage))
export default TableEditorPage
+7 -103
View File
@@ -1,112 +1,16 @@
import { useState } from 'react'
import router from 'next/router'
import { observer } from 'mobx-react-lite'
import { isUndefined } from 'lodash'
import type { PostgresTable } from '@supabase/postgres-meta'
import { NextPageWithLayout } from 'types'
import { useStore, withAuth } from 'hooks'
import { Entity } from 'data/entity-types/entity-type-query'
import { EmptyState } from 'components/interfaces/TableGridEditor'
import { TableEditorLayout } from 'components/layouts'
import { EmptyState, SidePanelEditor } from 'components/interfaces/TableGridEditor'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { ProjectContextFromParamsProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const snap = useTableEditorStateSnapshot()
const { meta, ui } = useStore()
const projectRef = ui.selectedProject?.ref
const [sidePanelKey, setSidePanelKey] = useState<'row' | 'column' | 'table'>()
const [isDeleting, setIsDeleting] = useState<boolean>(false)
const [isDuplicating, setIsDuplicating] = useState<boolean>(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState<PostgresTable>()
const [selectedTableToDelete, setSelectedTableToDelete] = useState<PostgresTable>()
const onAddTable = () => {
setSidePanelKey('table')
setIsDuplicating(false)
setSelectedTableToEdit(undefined)
}
const onEditTable = (entity: Entity) => {
setSidePanelKey('table')
setIsDuplicating(false)
const table = meta.tables.byId(entity.id)
setSelectedTableToEdit(table)
}
const onDeleteTable = (entity: Entity) => {
setIsDeleting(true)
const table = meta.tables.byId(entity.id)
setSelectedTableToDelete(table)
}
const onDuplicateTable = (entity: Entity) => {
setSidePanelKey('table')
setIsDuplicating(true)
const table = meta.tables.byId(entity.id)
setSelectedTableToEdit(table)
}
const onClosePanel = () => {
setSidePanelKey(undefined)
}
const onConfirmDeleteTable = async () => {
try {
await meta.tables.del(selectedTableToDelete!.id)
setIsDeleting(false)
ui.setNotification({
category: 'success',
message: `Successfully deleted ${selectedTableToDelete!.name}`,
})
await meta.views.loadBySchema(snap.selectedSchemaName)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to delete ${selectedTableToDelete!.name}`,
})
}
}
return (
<TableEditorLayout
onAddTable={onAddTable}
onEditTable={onEditTable}
onDeleteTable={onDeleteTable}
onDuplicateTable={onDuplicateTable}
>
<EmptyState onAddTable={onAddTable} />
{/* On this page it'll only handle tables */}
<ConfirmationModal
danger
visible={isDeleting && !isUndefined(selectedTableToDelete)}
header={
<span className="break-words">{`Confirm deletion of table "${selectedTableToDelete?.name}"`}</span>
}
description={`Are you sure you want to delete the selected table? This action cannot be undone`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => setIsDeleting(false)}
onSelectConfirm={onConfirmDeleteTable}
/>
<SidePanelEditor
isDuplicating={isDuplicating}
selectedTableToEdit={selectedTableToEdit}
sidePanelKey={sidePanelKey}
closePanel={onClosePanel}
onTableCreated={(table: any) => router.push(`/project/${projectRef}/editor/${table.id}`)}
/>
</TableEditorLayout>
)
return <EmptyState />
}
TableEditorPage.getLayout = (page) => (
<ProjectContextFromParamsProvider>{page}</ProjectContextFromParamsProvider>
<ProjectContextFromParamsProvider>
<TableEditorLayout>{page}</TableEditorLayout>
</ProjectContextFromParamsProvider>
)
export default withAuth(observer(TableEditorPage))
export default TableEditorPage
+141
View File
@@ -1,12 +1,153 @@
import { PostgresColumn } from '@supabase/postgres-meta'
import { Dictionary } from 'components/grid'
import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
import { JsonEditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types'
import { PropsWithChildren, createContext, useContext, useRef } from 'react'
import { proxy, useSnapshot } from 'valtio'
type ForeignKey = {
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
row: Dictionary<any>
column: PostgresColumn
}
export type SidePanel =
| { type: 'row'; row?: Dictionary<any> }
| { type: 'column'; column?: PostgresColumn }
| { type: 'table'; mode: 'new' | 'edit' | 'duplicate' }
| { type: 'json'; jsonValue: JsonEditValue }
| {
type: 'foreign-row-selector'
foreignKey: ForeignKey
}
| { type: 'csv-import' }
export type ConfirmationDialog =
| { type: 'table'; isDeleteWithCascade: boolean }
| { type: 'column'; column: PostgresColumn; isDeleteWithCascade: boolean }
export type UIState =
| {
open: 'none'
}
| {
open: 'side-panel'
sidePanel: SidePanel
}
| {
open: 'confirmation-dialog'
confirmationDialog: ConfirmationDialog
}
export const createTableEditorState = () => {
const state = proxy({
selectedSchemaName: 'public',
setSelectedSchemaName: (schemaName: string) => {
state.selectedSchemaName = schemaName
},
ui: { open: 'none' } as UIState,
get sidePanel() {
return state.ui.open === 'side-panel' ? state.ui.sidePanel : undefined
},
get confirmationDialog() {
return state.ui.open === 'confirmation-dialog' ? state.ui.confirmationDialog : undefined
},
closeSidePanel: () => {
state.ui = { open: 'none' }
},
closeConfirmationDialog: () => {
state.ui = { open: 'none' }
},
/* Tables */
onAddTable: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'table', mode: 'new' },
}
},
onEditTable: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'table', mode: 'edit' },
}
},
onDuplicateTable: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'table', mode: 'duplicate' },
}
},
onDeleteTable: () => {
state.ui = {
open: 'confirmation-dialog',
confirmationDialog: { type: 'table', isDeleteWithCascade: false },
}
},
/* Columns */
onAddColumn: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'column' },
}
},
onEditColumn: (column: PostgresColumn) => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'column', column },
}
},
onDeleteColumn: (column: PostgresColumn) => {
state.ui = {
open: 'confirmation-dialog',
confirmationDialog: { type: 'column', column, isDeleteWithCascade: false },
}
},
/* Rows */
onAddRow: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'row' },
}
},
onEditRow: (row: Dictionary<any>) => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'row', row },
}
},
/* Misc */
onExpandJSONEditor: (jsonValue: JsonEditValue) => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'json', jsonValue },
}
},
onEditForeignKeyColumnValue: (foreignKey: ForeignKey) => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'foreign-row-selector', foreignKey },
}
},
onImportData: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'csv-import' },
}
},
/* Utils */
toggleConfirmationIsWithCascade: (overrideIsDeleteWithCascade?: boolean) => {
if (state.ui.open === 'confirmation-dialog') {
state.ui.confirmationDialog.isDeleteWithCascade =
overrideIsDeleteWithCascade ?? !state.ui.confirmationDialog.isDeleteWithCascade
}
},
})
return state