mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
refactor to valtio table editor state
This commit is contained in:
1 parent
faecbd3736
commit
10e541a2de
13 files changed
+740
-1026
No files matched your search
@@ -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
|
||||
@@ -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...
|
||||
*/
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user