diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx index 92e509c6956..362a2a0314e 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx @@ -8,12 +8,14 @@ interface DeleteEnumeratedTypeModalProps { visible: boolean selectedEnumeratedType?: any onClose: () => void + onDelete?: () => void } const DeleteEnumeratedTypeModal = ({ visible, selectedEnumeratedType, onClose, + onDelete, }: DeleteEnumeratedTypeModalProps) => { const { data: project } = useSelectedProjectQuery() const { mutate: deleteEnumeratedType, isLoading: isDeleting } = useEnumeratedTypeDeleteMutation({ @@ -29,6 +31,7 @@ const DeleteEnumeratedTypeModal = ({ if (project?.connectionString === undefined) return console.error('Project connectionString required') + onDelete?.() deleteEnumeratedType({ projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index dae8ca6d2ba..98a4bda35ee 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -1,16 +1,16 @@ import { Edit, MoreVertical, Search, Trash } from 'lucide-react' -import { useState } from 'react' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useRef, useState } from 'react' +import { toast } from 'sonner' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { - EnumeratedType, - useEnumeratedTypesQuery, -} from 'data/enumerated-types/enumerated-types-query' +import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { Button, @@ -36,14 +36,33 @@ export const EnumeratedTypes = () => { const { data: project } = useSelectedProjectQuery() const [search, setSearch] = useState('') const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const [showCreateTypePanel, setShowCreateTypePanel] = useState(false) - const [selectedTypeToEdit, setSelectedTypeToEdit] = useState() - const [selectedTypeToDelete, setSelectedTypeToDelete] = useState() + const deletingTypeIdRef = useRef(null) const { data, error, isLoading, isError, isSuccess } = useEnumeratedTypesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) + + const [showCreateTypePanel, setShowCreateTypePanel] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { value: typeToEdit, setValue: setSelectedTypeIdToEdit } = useQueryStateWithSelect({ + urlKey: 'edit', + select: (id) => (id ? data?.find((type) => type.id.toString() === id) : undefined), + enabled: !!data, + onError: () => toast.error(`Enumerated Type not found`), + }) + + const { value: typeToDelete, setValue: setSelectedTypeIdToDelete } = useQueryStateWithSelect({ + urlKey: 'delete', + select: (id) => (id ? data?.find((type) => type.id.toString() === id) : undefined), + enabled: !!data, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingTypeIdRef, selectedId, `Enumerated Type not found`), + }) + const enumeratedTypes = (data ?? []).filter((type) => type.enums.length > 0) const filteredEnumeratedTypes = search.length > 0 @@ -150,14 +169,14 @@ export const EnumeratedTypes = () => { setSelectedTypeToEdit(type)} + onClick={() => setSelectedTypeIdToEdit(type.id.toString())} >

Update type

setSelectedTypeToDelete(type)} + onClick={() => setSelectedTypeIdToDelete(type.id.toString())} >

Delete type

@@ -182,15 +201,20 @@ export const EnumeratedTypes = () => { /> setSelectedTypeToEdit(undefined)} + visible={!!typeToEdit} + selectedEnumeratedType={typeToEdit} + onClose={() => setSelectedTypeIdToEdit(null)} /> setSelectedTypeToDelete(undefined)} + visible={!!typeToDelete} + selectedEnumeratedType={typeToDelete} + onClose={() => setSelectedTypeIdToDelete(null)} + onDelete={() => { + if (typeToDelete) { + deletingTypeIdRef.current = typeToDelete.id.toString() + } + }} /> ) diff --git a/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx b/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx index 5bae47e0cb6..235e48b656a 100644 --- a/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx +++ b/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx @@ -1,32 +1,34 @@ -import { toast } from 'sonner' - -import { useDatabaseFunctionDeleteMutation } from 'data/database-functions/database-functions-delete-mutation' -import { DatabaseFunction } from 'data/database-functions/database-functions-query' +import type { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' interface DeleteFunctionProps { func?: DatabaseFunction visible: boolean - setVisible: (value: boolean) => void + setVisible: (value: string | null) => void + onDelete: (params: { + func: DatabaseFunction + projectRef: string + connectionString?: string | null + }) => void + isLoading: boolean } -export const DeleteFunction = ({ func, visible, setVisible }: DeleteFunctionProps) => { +export const DeleteFunction = ({ + func, + visible, + setVisible, + onDelete, + isLoading, +}: DeleteFunctionProps) => { const { data: project } = useSelectedProjectQuery() const { name, schema } = func ?? {} - const { mutate: deleteDatabaseFunction, isLoading } = useDatabaseFunctionDeleteMutation({ - onSuccess: () => { - toast.success(`Successfully removed function ${name}`) - setVisible(false) - }, - }) - async function handleDelete() { if (!func) return console.error('Function is required') if (!project) return console.error('Project is required') - deleteDatabaseFunction({ + onDelete({ func, projectRef: project.ref, connectionString: project.connectionString, @@ -38,7 +40,7 @@ export const DeleteFunction = ({ func, visible, setVisible }: DeleteFunctionProp setVisible(!visible)} + onCancel={() => setVisible(null)} onConfirm={handleDelete} title="Delete this function" loading={isLoading} diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index cc3c9f64790..c4de9497432 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -21,6 +21,7 @@ import { TableCell, TableRow, } from 'ui' +import type { DatabaseFunction } from 'data/database-functions/database-functions-query' interface FunctionListProps { schema: string @@ -31,6 +32,7 @@ interface FunctionListProps { duplicateFunction: (fn: any) => void editFunction: (fn: any) => void deleteFunction: (fn: any) => void + functions: DatabaseFunction[] } const FunctionList = ({ @@ -42,17 +44,13 @@ const FunctionList = ({ duplicateFunction = noop, editFunction = noop, deleteFunction = noop, + functions, }: FunctionListProps) => { const router = useRouter() const { data: selectedProject } = useSelectedProjectQuery() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() - const { data: functions } = useDatabaseFunctionsQuery({ - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - }) - const filteredFunctions = (functions ?? []).filter((x) => { const matchesName = includes(x.name.toLowerCase(), filterString.toLowerCase()) const matchesReturnType = diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index d504ba1bc1a..30b5aef655e 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -1,9 +1,9 @@ -import type { PostgresFunction } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop } from 'lodash' import { Search } from 'lucide-react' import { useRouter } from 'next/router' -import { parseAsJson, useQueryState } from 'nuqs' +import { parseAsBoolean, parseAsJson, useQueryState } from 'nuqs' +import { useRef } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' @@ -13,10 +13,12 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query' +import { useDatabaseFunctionDeleteMutation } from 'data/database-functions/database-functions-delete-mutation' import { useSchemasQuery } from 'data/database/schemas-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' @@ -36,26 +38,80 @@ import { } from 'components/interfaces/Reports/v2/ReportsSelectFilter' import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' +import type { DatabaseFunction } from 'data/database-functions/database-functions-query' -interface FunctionsListProps { - createFunction: () => void - duplicateFunction: (fn: PostgresFunction) => void - editFunction: (fn: PostgresFunction) => void - deleteFunction: (fn: PostgresFunction) => void -} +import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings' +import { CreateFunction } from 'components/interfaces/Database/Functions/CreateFunction' +import { DeleteFunction } from 'components/interfaces/Database/Functions/DeleteFunction' +import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' -const FunctionsList = ({ - createFunction = noop, - editFunction = noop, - deleteFunction = noop, - duplicateFunction = noop, -}: FunctionsListProps) => { +const createFunctionSnippet = `create function function_name() +returns void +language plpgsql +as $$ +begin + -- Write your function logic here +end; +$$;` + +const FunctionsList = () => { const router = useRouter() const { search } = useParams() const { data: project } = useSelectedProjectQuery() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() + const isInlineEditorEnabled = useIsInlineEditorEnabled() + const { + setValue: setEditorPanelValue, + setTemplates: setEditorPanelTemplates, + setInitialPrompt: setEditorPanelInitialPrompt, + } = useEditorPanelStateSnapshot() + + // Track the ID being deleted to exclude it from error checking + const deletingFunctionIdRef = useRef(null) + + const createFunction = () => { + setSelectedFunctionIdToDuplicate(null) + if (isInlineEditorEnabled) { + setEditorPanelInitialPrompt('Create a new database function that...') + setEditorPanelValue(createFunctionSnippet) + setEditorPanelTemplates([]) + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + } else { + setShowCreateFunctionForm(true) + } + } + + const duplicateFunction = (fn: DatabaseFunction) => { + if (isInlineEditorEnabled) { + const dupFn = { + ...fn, + name: `${fn.name}_duplicate`, + } + setEditorPanelInitialPrompt('Create new database function that...') + setEditorPanelValue(dupFn.complete_statement) + setEditorPanelTemplates([]) + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + } else { + setSelectedFunctionIdToDuplicate(fn.id.toString()) + } + } + + const editFunction = (fn: DatabaseFunction) => { + setSelectedFunctionIdToDuplicate(null) + if (isInlineEditorEnabled) { + setEditorPanelValue(fn.complete_statement) + setEditorPanelTemplates([]) + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + } else { + setSelectedFunctionToEdit(fn.id.toString()) + } + } + + const deleteFunction = (fn: DatabaseFunction) => { + setSelectedFunctionToDelete(fn.id.toString()) + } const filterString = search ?? '' @@ -114,12 +170,56 @@ const FunctionsList = ({ ...(hasInvoker ? [{ label: 'Invoker', value: 'invoker' }] : []), ] + const [showCreateFunctionForm, setShowCreateFunctionForm] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { setValue: setSelectedFunctionToEdit, value: functionToEdit } = useQueryStateWithSelect({ + urlKey: 'edit', + select: (id: string) => (id ? functions?.find((fn) => fn.id.toString() === id) : undefined), + enabled: !!functions, + onError: () => toast.error(`Function not found`), + }) + + const { setValue: setSelectedFunctionIdToDuplicate, value: functionToDuplicate } = + useQueryStateWithSelect({ + urlKey: 'duplicate', + select: (id: string) => { + if (!id) return undefined + const original = functions?.find((fn) => fn.id.toString() === id) + return original ? { ...original, name: `${original.name}_duplicate` } : undefined + }, + enabled: !!functions, + onError: () => toast.error(`Function not found`), + }) + + const { setValue: setSelectedFunctionToDelete, value: functionToDelete } = + useQueryStateWithSelect({ + urlKey: 'delete', + select: (id: string) => (id ? functions?.find((fn) => fn.id.toString() === id) : undefined), + enabled: !!functions, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingFunctionIdRef, selectedId, `Function not found`), + }) + + const { mutate: deleteDatabaseFunction, isLoading: isDeletingFunction } = + useDatabaseFunctionDeleteMutation({ + onSuccess: (_, variables) => { + toast.success(`Successfully removed function ${variables.func.name}`) + setSelectedFunctionToDelete(null) + }, + onError: () => { + deletingFunctionIdRef.current = null + }, + }) + if (isLoading) return if (isError) return return ( <> - {(functions ?? []).length == 0 ? ( + {(functions ?? []).length === 0 ? (
)} + + {/* Create Function */} + { + setShowCreateFunctionForm(false) + }} + /> + + {/* Edit or Duplicate Function */} + { + setSelectedFunctionToEdit(null) + setSelectedFunctionIdToDuplicate(null) + }} + isDuplicating={!!functionToDuplicate} + /> + + [0]) => { + deletingFunctionIdRef.current = params.func.id.toString() + deleteDatabaseFunction(params) + }} + isLoading={isDeletingFunction} + /> ) } diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 8cabd076c5c..c10c7e8da91 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -1,6 +1,7 @@ import { sortBy } from 'lodash' import { AlertCircle, Search, Trash } from 'lucide-react' -import { useEffect, useState } from 'react' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' @@ -9,10 +10,11 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import SchemaSelector from 'components/ui/SchemaSelector' import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabaseIndexDeleteMutation } from 'data/database-indexes/index-delete-mutation' -import { DatabaseIndex, useIndexesQuery } from 'data/database-indexes/indexes-query' +import { useIndexesQuery, type DatabaseIndex } from 'data/database-indexes/indexes-query' import { useSchemasQuery } from 'data/database/schemas-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { Button, @@ -36,9 +38,7 @@ const Indexes = () => { const [search, setSearch] = useState('') const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const [showCreateIndex, setShowCreateIndex] = useState(false) - const [selectedIndex, setSelectedIndex] = useState() - const [selectedIndexToDelete, setSelectedIndexToDelete] = useState() + const deletingIndexNameRef = useRef(null) const { data: allIndexes, @@ -51,6 +51,28 @@ const Indexes = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) + + const [showCreateIndex, setShowCreateIndex] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { setValue: setSelectedIndexName, value: selectedIndex } = useQueryStateWithSelect({ + urlKey: 'edit', + select: (id) => (id ? allIndexes?.find((idx) => idx.name === id) : undefined), + enabled: !!allIndexes, + onError: () => toast.error(`Index not found`), + }) + + const { setValue: setSelectedIndexNameToDelete, value: selectedIndexToDelete } = + useQueryStateWithSelect({ + urlKey: 'delete', + select: (id) => (id ? allIndexes?.find((idx) => idx.name === id) : undefined), + enabled: !!allIndexes, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingIndexNameRef, selectedId, `Index not found`), + }) + const { data: schemas, isLoading: isLoadingSchemas, @@ -63,7 +85,7 @@ const Indexes = () => { const { mutate: deleteIndex, isLoading: isExecuting } = useDatabaseIndexDeleteMutation({ onSuccess: async () => { - setSelectedIndexToDelete(undefined) + setSelectedIndexNameToDelete(null) toast.success('Successfully deleted index') }, }) @@ -79,6 +101,7 @@ const Indexes = () => { const onConfirmDeleteIndex = (index: DatabaseIndex) => { if (!project) return console.error('Project is required') + deletingIndexNameRef.current = index.name deleteIndex({ projectRef: project.ref, connectionString: project.connectionString, @@ -195,7 +218,10 @@ const Indexes = () => {
- {!isSchemaLocked && ( @@ -204,7 +230,7 @@ const Indexes = () => { type="text" className="px-1" icon={} - onClick={() => setSelectedIndexToDelete(index)} + onClick={() => setSelectedIndexNameToDelete(index.name)} /> )}
@@ -221,14 +247,14 @@ const Indexes = () => { Index: {selectedIndex?.name} } - onCancel={() => setSelectedIndex(undefined)} + onCancel={() => setSelectedIndexName(null)} >
@@ -248,7 +274,7 @@ const Indexes = () => { variant="warning" size="medium" loading={isExecuting} - visible={selectedIndexToDelete !== undefined} + visible={!!selectedIndexToDelete} title={ <> Confirm to delete index {selectedIndexToDelete?.name} @@ -259,7 +285,7 @@ const Indexes = () => { onConfirm={() => selectedIndexToDelete !== undefined ? onConfirmDeleteIndex(selectedIndexToDelete) : {} } - onCancel={() => setSelectedIndexToDelete(undefined)} + onCancel={() => setSelectedIndexNameToDelete(null)} alert={{ title: 'This action cannot be undone', description: diff --git a/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx b/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx index 91f868bc7b8..2b42240b506 100644 --- a/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx +++ b/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx @@ -9,9 +9,10 @@ interface DeleteRoleModalProps { role: PostgresRole visible: boolean onClose: () => void + onDelete?: () => void } -export const DeleteRoleModal = ({ role, visible, onClose }: DeleteRoleModalProps) => { +export const DeleteRoleModal = ({ role, visible, onClose, onDelete }: DeleteRoleModalProps) => { const { data: project } = useSelectedProjectQuery() const { mutate: deleteDatabaseRole, isLoading: isDeleting } = useDatabaseRoleDeleteMutation({ @@ -24,6 +25,7 @@ export const DeleteRoleModal = ({ role, visible, onClose }: DeleteRoleModalProps const deleteRole = async () => { if (!project) return console.error('Project is required') if (!role) return console.error('Failed to delete role: role is missing') + onDelete?.() deleteDatabaseRole({ projectRef: project.ref, connectionString: project.connectionString, diff --git a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx index 876ffea6e01..c4c5b82dee4 100644 --- a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx @@ -24,7 +24,7 @@ import { ROLE_PERMISSIONS } from './Roles.constants' interface RoleRowProps { role: PgRole disabled?: boolean - onSelectDelete: (role: PgRole) => void + onSelectDelete: (role: string) => void } export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => { @@ -141,7 +141,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps className="space-x-2" onClick={(event) => { event.stopPropagation() - onSelectDelete(role) + onSelectDelete(role.id.toString()) }} > diff --git a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx index dfededaea12..0c6fe60f15b 100644 --- a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -1,7 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition, sortBy } from 'lodash' import { Plus, Search, X } from 'lucide-react' -import { useState } from 'react' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useRef, useState } from 'react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import NoSearchResults from 'components/ui/NoSearchResults' @@ -10,12 +11,14 @@ import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { Badge, Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { CreateRolePanel } from './CreateRolePanel' import { DeleteRoleModal } from './DeleteRoleModal' import { RoleRow } from './RoleRow' import { RoleRowSkeleton } from './RoleRowSkeleton' import { SUPABASE_ROLES } from './Roles.constants' +import type { PostgresRole } from '@supabase/postgres-meta' type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number] @@ -24,8 +27,7 @@ export const RolesList = () => { const [filterString, setFilterString] = useState('') const [filterType, setFilterType] = useState<'all' | 'active'>('all') - const [isCreatingRole, setIsCreatingRole] = useState(false) - const [selectedRoleToDelete, setSelectedRoleToDelete] = useState() + const deletingRoleIdRef = useRef(null) const { can: canUpdateRoles } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, @@ -42,6 +44,20 @@ export const RolesList = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) + + const [isCreatingRole, setIsCreatingRole] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { setValue: setSelectedRoleIdToDelete, value: roleToDelete } = useQueryStateWithSelect({ + urlKey: 'delete', + select: (id: string) => (id ? data?.find((role) => role.id.toString() === id) : undefined), + enabled: !!data, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingRoleIdRef, selectedId, `Database Role not found`), + }) + const roles = sortBy(data ?? [], (r) => r.name.toLocaleLowerCase()) const filteredRoles = ( @@ -182,7 +198,7 @@ export const RolesList = () => { disabled key={role.id} role={role} - onSelectDelete={setSelectedRoleToDelete} + onSelectDelete={setSelectedRoleIdToDelete} /> ))}
@@ -199,7 +215,7 @@ export const RolesList = () => { key={role.id} disabled={!canUpdateRoles} role={role} - onSelectDelete={setSelectedRoleToDelete} + onSelectDelete={setSelectedRoleIdToDelete} /> ))}
@@ -212,9 +228,14 @@ export const RolesList = () => { setIsCreatingRole(false)} /> setSelectedRoleToDelete(undefined)} + role={roleToDelete as unknown as PostgresRole} + visible={!!roleToDelete} + onClose={() => setSelectedRoleIdToDelete(null)} + onDelete={() => { + if (roleToDelete) { + deletingRoleIdRef.current = roleToDelete.id.toString() + } + }} /> ) diff --git a/apps/studio/hooks/misc/useQueryStateWithSelect.ts b/apps/studio/hooks/misc/useQueryStateWithSelect.ts new file mode 100644 index 00000000000..f6afa54d7ae --- /dev/null +++ b/apps/studio/hooks/misc/useQueryStateWithSelect.ts @@ -0,0 +1,58 @@ +import { MutableRefObject, useEffect, useMemo } from 'react' +import { parseAsString, useQueryState } from 'nuqs' +import { toast } from 'sonner' + +/** + * Hook for managing URL query parameters with a custom select function and error handling. + * + * @param enabled - Whether error handling is active (shows error when selectedId exists but select returns undefined) + * @param urlKey - The query parameter key (e.g., 'edit', 'delete') + * @param select - Function to transform the selected ID into the desired value (returns undefined if not found) + * @param onError - Callback invoked when enabled is true and selectedId exists but select returns undefined + * + * @returns Object with: + * - value: The result of select(selectedId) or undefined + * - setValue: Function to set/clear the selected ID in the URL + */ +export function useQueryStateWithSelect({ + enabled, + urlKey, + select, + onError, +}: { + enabled: boolean + urlKey: string + select: (id: string) => T | undefined + onError: (error: Error, selectedId: string) => void +}) { + const [selectedId, setSelectedId] = useQueryState( + urlKey, + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + + const value = useMemo(() => (selectedId ? select(selectedId) : undefined), [selectedId, select]) + + useEffect(() => { + if (enabled && selectedId && !value) { + onError(new Error(`not found`), selectedId) + setSelectedId(null) + } + }, [enabled, onError, selectedId, setSelectedId, value]) + + return { + value, + setValue: setSelectedId as (value: string | null) => void, + } +} + +export const handleErrorOnDelete = ( + deletingIdRef: MutableRefObject, + selectedId: string, + errorMessage: string +) => { + if (selectedId !== deletingIdRef.current) { + toast.error(errorMessage) + } else { + deletingIdRef.current = null + } +} diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index d1446917a0e..a240f6207e0 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -1,135 +1,37 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useState } from 'react' -import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings' -import { CreateFunction } from 'components/interfaces/Database/Functions/CreateFunction' -import { DeleteFunction } from 'components/interfaces/Database/Functions/DeleteFunction' import FunctionsList from 'components/interfaces/Database/Functions/FunctionsList/FunctionsList' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' -import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' -import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { DOCS_URL } from 'lib/constants' -import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import type { NextPageWithLayout } from 'types' const DatabaseFunctionsPage: NextPageWithLayout = () => { - const isInlineEditorEnabled = useIsInlineEditorEnabled() - const { openSidebar } = useSidebarManagerSnapshot() - const { - setValue: setEditorPanelValue, - setTemplates: setEditorPanelTemplates, - setInitialPrompt: setEditorPanelInitialPrompt, - } = useEditorPanelStateSnapshot() - const { can: canReadFunctions, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'functions' ) - const [isDuplicating, setIsDuplicating] = useState(false) - const [selectedFunction, setSelectedFunction] = useState() - const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false) - const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false) - - const createFunction = () => { - setIsDuplicating(false) - if (isInlineEditorEnabled) { - setEditorPanelInitialPrompt('Create a new database function that...') - setEditorPanelValue(`create function function_name() -returns void -language plpgsql -as $$ -begin - -- Write your function logic here -end; -$$;`) - setEditorPanelTemplates([]) - openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) - } else { - setSelectedFunction(undefined) - setShowCreateFunctionForm(true) - } - } - - const duplicateFunction = (fn: DatabaseFunction) => { - setIsDuplicating(true) - - const dupFn = { - ...fn, - name: `${fn.name}_duplicate`, - } - - if (isInlineEditorEnabled) { - setEditorPanelInitialPrompt('Create new database function that...') - setEditorPanelValue(dupFn.complete_statement) - setEditorPanelTemplates([]) - openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) - } else { - setSelectedFunction(dupFn) - setShowCreateFunctionForm(true) - } - } - - const editFunction = (fn: DatabaseFunction) => { - setIsDuplicating(false) - if (isInlineEditorEnabled) { - setEditorPanelValue(fn.complete_statement) - setEditorPanelTemplates([]) - openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) - } else { - setSelectedFunction(fn) - setShowCreateFunctionForm(true) - } - } - - const deleteFunction = (fn: any) => { - setSelectedFunction(fn) - setShowDeleteFunctionForm(true) - } - if (isPermissionsLoaded && !canReadFunctions) { return } return ( - <> - - -
- - -
-
-
- { - setShowCreateFunctionForm(false) - setIsDuplicating(false) - }} - isDuplicating={isDuplicating} - /> - - + + +
+ + +
+
+
) }