From 10e541a2de9fe0bed495d824fa7bf617d993eaab Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 3 May 2023 16:37:35 +1000 Subject: [PATCH] refactor to valtio table editor state --- studio/components/grid/SupabaseGrid.tsx | 312 ++++++++------- .../DeleteConfirmationDialogs.tsx | 236 +++++++++++ .../interfaces/TableGridEditor/EmptyState.tsx | 10 +- .../SidePanelEditor/SidePanelEditor.tsx | 111 +++--- .../TableGridEditor/TableGridEditor.tsx | 134 ++----- .../TableEditorLayout/EntityListItem.tsx | 26 +- .../TableEditorLayout/TableEditorLayout.tsx | 37 +- .../TableEditorLayout/TableEditorMenu.tsx | 31 +- studio/hooks/misc/useTable.ts | 13 +- .../pages/project/[ref]/database/tables.tsx | 230 +++-------- studio/pages/project/[ref]/editor/[id].tsx | 375 +----------------- studio/pages/project/[ref]/editor/index.tsx | 110 +---- studio/state/table-editor.tsx | 141 +++++++ 13 files changed, 740 insertions(+), 1026 deletions(-) create mode 100644 studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx diff --git a/studio/components/grid/SupabaseGrid.tsx b/studio/components/grid/SupabaseGrid.tsx index 94e65bfa996..17434cb5457 100644 --- a/studio/components/grid/SupabaseGrid.tsx +++ b/studio/components/grid/SupabaseGrid.tsx @@ -25,7 +25,10 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte /** Supabase Grid: React component to render database table */ -export const SupabaseGrid = forwardRef((props, ref) => { +export const SupabaseGrid = forwardRef(function SupabaseGrid( + props, + ref +) { const monaco = useMonaco() const _props = cleanupProps(props) const { theme } = _props @@ -57,166 +60,171 @@ export const SupabaseGrid = forwardRef((prop ) }) -const SupabaseGridLayout = forwardRef((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( + 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(null) - const [mounted, setMounted] = useState(false) + const gridRef = useRef(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) { dispatch({ - type: 'SET_ROWS_COUNT', - payload: data.rows.length, + type: 'ADD_NEW_ROW', + payload: row, }) }, - } - ) - - useImperativeHandle(ref, () => ({ - rowAdded(row: Dictionary) { - dispatch({ - type: 'ADD_NEW_ROW', - payload: row, - }) - }, - rowEdited(row: Dictionary, 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, 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 ( -
-
- {showCustomChildren && children !== undefined ? ( - <>{children} - ) : ( - <> - -
- - - )} + useEffect(() => { + dispatch({ + type: 'INIT_CALLBACK', + payload: { ...props }, + }) + }, []) - {mounted && - createPortal(, document.body)} -
- ) -}) + 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 ( +
+
+ {showCustomChildren && children !== undefined ? ( + <>{children} + ) : ( + <> + +
+ + + )} + + {mounted && + createPortal( + , + document.body + )} +
+ ) + } +) diff --git a/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx new file mode 100644 index 00000000000..6e373c9c774 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -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 ( + <> + { + snap.closeConfirmationDialog() + }} + onSelectConfirm={onConfirmDeleteColumn} + > + +
+

+ Are you sure you want to delete the selected column? This action cannot be undone. +

+ snap.toggleConfirmationIsWithCascade()} + /> + {isDeleteWithCascade && ( + +

+ All dependent objects will be removed, as will any objects that depend on them, + recursively. +

+ + + + + +
+ )} +
+
+
+ {`Confirm deletion of table "${selectedTable?.name}"`} + } + buttonLabel="Delete" + buttonLoadingLabel="Deleting" + onSelectCancel={() => { + snap.closeConfirmationDialog() + }} + onSelectConfirm={onConfirmDeleteTable} + > + +
+

+ Are you sure you want to delete the selected table? This action cannot be undone. +

+ snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)} + /> + {isDeleteWithCascade && ( + +

+ All dependent objects will be removed, as will any objects that depend on them, + recursively. +

+ + + + + +
+ )} +
+
+
+ + ) +} + +export default DeleteConfirmationDialogs diff --git a/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/studio/components/interfaces/TableGridEditor/EmptyState.tsx index 8aeb7128028..2972d3ae944 100644 --- a/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -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) => {

There are no tables available in this schema.

@@ -51,7 +49,7 @@ const EmptyState = ({ onAddTable }: EmptyStateProps) => {

Select a table from the navigation panel on the left to view its data diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index b2d544f4a1e..379d83f2d02 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -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 - selectedColumnToEdit?: PostgresColumn - selectedTableToEdit?: PostgresTable - selectedValueForJsonEdit?: JsonEditValue - selectedForeignKeyToEdit?: { - foreignKey: NonNullable - row: any - column: any - } - sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' | 'csv-import' - isDuplicating?: boolean - closePanel: () => void onRowCreated?: (row: Dictionary) => void onRowUpdated?: (row: Dictionary, 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) && ( setIsEdited(true)} @@ -539,41 +529,58 @@ const SidePanelEditor = ({ )} {!isUndefined(selectedTable) && ( setIsEdited(true)} /> )} setIsEdited(true)} /> { setIsClosingPanel(false) setIsEdited(false) - closePanel() + snap.closeSidePanel() }} > diff --git a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index d87be9046c1..ebeeb3b974e 100644 --- a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -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 - selectedColumnToEdit?: PostgresColumn - selectedTableToEdit?: PostgresTable - selectedValueForJsonEdit?: JsonEditValue - selectedForeignKeyToEdit?: { - foreignKey: NonNullable - row: any - column: any - } - - onAddRow: () => void - onEditRow: (row: Dictionary) => void - onAddColumn: () => void - onEditColumn: (column: PostgresColumn) => void - onDeleteColumn: (column: PostgresColumn) => void - onExpandJSONEditor: (column: string, row: any) => void - onEditForeignKeyColumnValue: (args: { - foreignKey: NonNullable - 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 } - 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 ;(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) && ( )} diff --git a/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 2e0d5fdeb19..2af073982ed 100644 --- a/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -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={} onClick={(e) => { e.stopPropagation() - onEditTable(entity) + snap.onEditTable() }} - disabled={isLoadingTableMetadata} > Edit Table , @@ -148,9 +136,8 @@ const EntityListItem = ({ icon={} onClick={(e) => { e.stopPropagation() - onDuplicateTable(entity) + snap.onDuplicateTable() }} - disabled={isLoadingTableMetadata} > Duplicate Table , @@ -170,9 +157,8 @@ const EntityListItem = ({ icon={} onClick={(e) => { e.stopPropagation() - onDeleteTable(entity) + snap.onDeleteTable() }} - disabled={isLoadingTableMetadata} > Delete Table , diff --git a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index d27a43a4c85..a3e626ee48e 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -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) => { +const TableEditorLayout = ({ children }: PropsWithChildren) => { const { vault, meta, ui } = useStore() const canReadTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') @@ -47,26 +34,16 @@ const TableEditorLayout = ({ if (!canReadTables) { return ( - + - + ) } return ( - - } - > + }> {children} - + ) } diff --git a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index a4a3ee92cf6..958ef89d8ce 100644 --- a/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -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 @@ -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 diff --git a/studio/hooks/misc/useTable.ts b/studio/hooks/misc/useTable.ts index 505ecf23e95..19647e03c88 100644 --- a/studio/hooks/misc/useTable.ts +++ b/studio/hooks/misc/useTable.ts @@ -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... */ diff --git a/studio/pages/project/[ref]/database/tables.tsx b/studio/pages/project/[ref]/database/tables.tsx index 60d7a31acd1..f8d723a8c81 100644 --- a/studio/pages/project/[ref]/database/tables.tsx +++ b/studio/pages/project/[ref]/database/tables.tsx @@ -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(false) - const [selectedTable, setSelectedTable] = useState() - const [sidePanelKey, setSidePanelKey] = useState<'column' | 'table'>() - - const [selectedColumnToEdit, setSelectedColumnToEdit] = useState() - const [selectedTableToEdit, setSelectedTableToEdit] = useState() - - const [selectedColumnToDelete, setSelectedColumnToDelete] = useState() - const [selectedTableToDelete, setSelectedTableToDelete] = useState() + 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 ( <>

- {isUndefined(selectedTable) ? ( - - ) : ( + {id !== undefined && selectedTable !== undefined ? ( setSelectedTable(undefined)} /> + ) : ( + { + setSelectedTable(table) + snap.onEditTable() + }} + onDeleteTable={(table) => { + setSelectedTable(table) + snap.onDeleteTable() + }} + onOpenTable={setSelectedTable} + /> )}
- {`Confirm deletion of table "${selectedTableToDelete?.name}"`} - } - buttonLabel="Delete" - buttonLoadingLabel="Deleting" - onSelectCancel={() => setIsDeleting(false)} - onSelectConfirm={onConfirmDeleteTable} - > - -

- Are you sure you want to delete the selected table? This action cannot be undone. -

-
-
- setIsDeleting(false)} - onSelectConfirm={onConfirmDeleteColumn} - > - -

- Are you sure you want to delete the selected column? This action cannot be undone. -

-
-
- + + ) } diff --git a/studio/pages/project/[ref]/editor/[id].tsx b/studio/pages/project/[ref]/editor/[id].tsx index 93d2646c4fc..31b390bbf67 100644 --- a/studio/pages/project/[ref]/editor/[id].tsx +++ b/studio/pages/project/[ref]/editor/[id].tsx @@ -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(false) - const [isDuplicating, setIsDuplicating] = useState(false) - const [isDeleteWithCascade, setIsDeleteWithCascade] = useState(false) - - const [selectedColumnToDelete, setSelectedColumnToDelete] = useState() - const [selectedTableToDelete, setSelectedTableToDelete] = useState() - - const [sidePanelKey, setSidePanelKey] = useState< - 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' | 'csv-import' - >() - const [selectedRowToEdit, setSelectedRowToEdit] = useState>() - const [selectedColumnToEdit, setSelectedColumnToEdit] = useState() - const [selectedTableToEdit, setSelectedTableToEdit] = useState() - const [selectedValueForJsonEdit, setSelectedValueForJsonEdit] = useState() - const [selectedForeignKeyToEdit, setSelectedForeignKeyToEdit] = useState<{ - foreignKey: NonNullable - row: any - column: any - }>() const { data: selectedTable, isLoading } = useTable(id) - const onAddRow = () => { - setSidePanelKey('row') - setSelectedRowToEdit(undefined) - } - - const onEditRow = (row: Dictionary) => { - 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 - 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 ( - + <> - { - setIsDeleting(false) - setSelectedColumnToDelete(undefined) - }} - onSelectConfirm={onConfirmDeleteColumn} - > - -
-

- Are you sure you want to delete the selected column? This action cannot be undone. -

- setIsDeleteWithCascade(!isDeleteWithCascade)} - /> - {isDeleteWithCascade && ( - -

- All dependent objects will be removed, as will any objects that depend on them, - recursively. -

- - - - - -
- )} -
-
-
- {`Confirm deletion of table "${selectedTableToDelete?.name}"`} - } - buttonLabel="Delete" - buttonLoadingLabel="Deleting" - onSelectCancel={() => { - setIsDeleting(false) - setSelectedTableToDelete(undefined) - }} - onSelectConfirm={onConfirmDeleteTable} - > - -
-

- Are you sure you want to delete the selected table? This action cannot be undone. -

- setIsDeleteWithCascade(!isDeleteWithCascade)} - /> - {isDeleteWithCascade && ( - -

- All dependent objects will be removed, as will any objects that depend on them, - recursively. -

- - - - - -
- )} -
-
-
-
+ + ) } TableEditorPage.getLayout = (page) => ( - {page} + + {page} + ) -export default withAuth(observer(TableEditorPage)) +export default TableEditorPage diff --git a/studio/pages/project/[ref]/editor/index.tsx b/studio/pages/project/[ref]/editor/index.tsx index b93b42e7bfb..0588fece332 100644 --- a/studio/pages/project/[ref]/editor/index.tsx +++ b/studio/pages/project/[ref]/editor/index.tsx @@ -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(false) - const [isDuplicating, setIsDuplicating] = useState(false) - const [selectedTableToEdit, setSelectedTableToEdit] = useState() - const [selectedTableToDelete, setSelectedTableToDelete] = useState() - - 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 ( - - - {/* On this page it'll only handle tables */} - {`Confirm deletion of table "${selectedTableToDelete?.name}"`} - } - 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} - /> - router.push(`/project/${projectRef}/editor/${table.id}`)} - /> - - ) + return } TableEditorPage.getLayout = (page) => ( - {page} + + {page} + ) -export default withAuth(observer(TableEditorPage)) +export default TableEditorPage diff --git a/studio/state/table-editor.tsx b/studio/state/table-editor.tsx index 94135e0d035..3b38c94f5e1 100644 --- a/studio/state/table-editor.tsx +++ b/studio/state/table-editor.tsx @@ -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 + row: Dictionary + column: PostgresColumn +} + +export type SidePanel = + | { type: 'row'; row?: Dictionary } + | { 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) => { + 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