Chore: add param routing on panels (#40184)

* chore(studio): add useQueryStateWithSelect hook to handle nested param routing on panels
* handleErrorOnDelete fn for useQueryStateWithSelect
This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2025-11-12 15:58:31 +01:00
1 parent 35fc021028
commit cb045c9559
11 files changed
+351 -184

No files matched your search

@@ -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,
@@ -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<EnumeratedType>()
const [selectedTypeToDelete, setSelectedTypeToDelete] = useState<EnumeratedType>()
const deletingTypeIdRef = useRef<string | null>(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 = () => {
<DropdownMenuContent side="bottom" align="end" className="w-32">
<DropdownMenuItem
className="space-x-2"
onClick={() => setSelectedTypeToEdit(type)}
onClick={() => setSelectedTypeIdToEdit(type.id.toString())}
>
<Edit size={14} />
<p>Update type</p>
</DropdownMenuItem>
<DropdownMenuItem
className="space-x-2"
onClick={() => setSelectedTypeToDelete(type)}
onClick={() => setSelectedTypeIdToDelete(type.id.toString())}
>
<Trash size={14} />
<p>Delete type</p>
@@ -182,15 +201,20 @@ export const EnumeratedTypes = () => {
/>
<EditEnumeratedTypeSidePanel
visible={selectedTypeToEdit !== undefined}
selectedEnumeratedType={selectedTypeToEdit}
onClose={() => setSelectedTypeToEdit(undefined)}
visible={!!typeToEdit}
selectedEnumeratedType={typeToEdit}
onClose={() => setSelectedTypeIdToEdit(null)}
/>
<DeleteEnumeratedTypeModal
visible={selectedTypeToDelete !== undefined}
selectedEnumeratedType={selectedTypeToDelete}
onClose={() => setSelectedTypeToDelete(undefined)}
visible={!!typeToDelete}
selectedEnumeratedType={typeToDelete}
onClose={() => setSelectedTypeIdToDelete(null)}
onDelete={() => {
if (typeToDelete) {
deletingTypeIdRef.current = typeToDelete.id.toString()
}
}}
/>
</div>
)
@@ -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
<TextConfirmModal
variant={'warning'}
visible={visible}
onCancel={() => setVisible(!visible)}
onCancel={() => setVisible(null)}
onConfirm={handleDelete}
title="Delete this function"
loading={isLoading}
@@ -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 =
@@ -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<string | null>(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 <GenericSkeletonLoader />
if (isError) return <AlertError error={error} subject="Failed to retrieve database functions" />
return (
<>
{(functions ?? []).length == 0 ? (
{(functions ?? []).length === 0 ? (
<div className="flex h-full w-full items-center justify-center">
<ProductEmptyState
title="Functions"
@@ -252,12 +352,43 @@ const FunctionsList = ({
duplicateFunction={duplicateFunction}
editFunction={editFunction}
deleteFunction={deleteFunction}
functions={functions ?? []}
/>
</TableBody>
</Table>
</Card>
</div>
)}
{/* Create Function */}
<CreateFunction
visible={showCreateFunctionForm}
onClose={() => {
setShowCreateFunctionForm(false)
}}
/>
{/* Edit or Duplicate Function */}
<CreateFunction
func={functionToEdit || functionToDuplicate}
visible={!!functionToEdit || !!functionToDuplicate}
onClose={() => {
setSelectedFunctionToEdit(null)
setSelectedFunctionIdToDuplicate(null)
}}
isDuplicating={!!functionToDuplicate}
/>
<DeleteFunction
func={functionToDelete}
visible={!!functionToDelete}
setVisible={setSelectedFunctionToDelete}
onDelete={(params: Parameters<typeof deleteDatabaseFunction>[0]) => {
deletingFunctionIdRef.current = params.func.id.toString()
deleteDatabaseFunction(params)
}}
isLoading={isDeletingFunction}
/>
</>
)
}
@@ -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<DatabaseIndex>()
const [selectedIndexToDelete, setSelectedIndexToDelete] = useState<DatabaseIndex>()
const deletingIndexNameRef = useRef<string | null>(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 = () => {
</TableCell>
<TableCell>
<div className="flex justify-end items-center space-x-2">
<Button type="default" onClick={() => setSelectedIndex(index)}>
<Button
type="default"
onClick={() => setSelectedIndexName(index.name)}
>
View definition
</Button>
{!isSchemaLocked && (
@@ -204,7 +230,7 @@ const Indexes = () => {
type="text"
className="px-1"
icon={<Trash />}
onClick={() => setSelectedIndexToDelete(index)}
onClick={() => setSelectedIndexNameToDelete(index.name)}
/>
)}
</div>
@@ -221,14 +247,14 @@ const Indexes = () => {
<SidePanel
size="xlarge"
visible={selectedIndex !== undefined}
visible={!!selectedIndex}
header={
<>
<span>Index:</span>
<code className="text-sm ml-2">{selectedIndex?.name}</code>
</>
}
onCancel={() => setSelectedIndex(undefined)}
onCancel={() => setSelectedIndexName(null)}
>
<div className="h-full">
<div className="relative h-full">
@@ -248,7 +274,7 @@ const Indexes = () => {
variant="warning"
size="medium"
loading={isExecuting}
visible={selectedIndexToDelete !== undefined}
visible={!!selectedIndexToDelete}
title={
<>
Confirm to delete index <code className="text-sm">{selectedIndexToDelete?.name}</code>
@@ -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:
@@ -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,
@@ -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())
}}
>
<Trash className="text-red-800" size="14" strokeWidth={2} />
@@ -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<any>()
const deletingRoleIdRef = useRef<string | null>(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}
/>
))}
</div>
@@ -199,7 +215,7 @@ export const RolesList = () => {
key={role.id}
disabled={!canUpdateRoles}
role={role}
onSelectDelete={setSelectedRoleToDelete}
onSelectDelete={setSelectedRoleIdToDelete}
/>
))}
</div>
@@ -212,9 +228,14 @@ export const RolesList = () => {
<CreateRolePanel visible={isCreatingRole} onClose={() => setIsCreatingRole(false)} />
<DeleteRoleModal
role={selectedRoleToDelete}
visible={selectedRoleToDelete !== undefined}
onClose={() => setSelectedRoleToDelete(undefined)}
role={roleToDelete as unknown as PostgresRole}
visible={!!roleToDelete}
onClose={() => setSelectedRoleIdToDelete(null)}
onDelete={() => {
if (roleToDelete) {
deletingRoleIdRef.current = roleToDelete.id.toString()
}
}}
/>
</>
)
@@ -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<T>({
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<string | null>,
selectedId: string,
errorMessage: string
) => {
if (selectedId !== deletingIdRef.current) {
toast.error(errorMessage)
} else {
deletingIdRef.current = null
}
}
@@ -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<DatabaseFunction | undefined>()
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 <NoPermission isFullPage resourceText="view database functions" />
}
return (
<>
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12">
<FormHeader
title="Database Functions"
docsUrl={`${DOCS_URL}/guides/database/functions`}
/>
<FunctionsList
createFunction={createFunction}
duplicateFunction={duplicateFunction}
editFunction={editFunction}
deleteFunction={deleteFunction}
/>
</div>
</ScaffoldSection>
</ScaffoldContainer>
<CreateFunction
func={selectedFunction}
visible={showCreateFunctionForm}
onClose={() => {
setShowCreateFunctionForm(false)
setIsDuplicating(false)
}}
isDuplicating={isDuplicating}
/>
<DeleteFunction
func={selectedFunction}
visible={showDeleteFunctionForm}
setVisible={setShowDeleteFunctionForm}
/>
</>
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12">
<FormHeader
title="Database Functions"
docsUrl={`${DOCS_URL}/guides/database/functions`}
/>
<FunctionsList />
</div>
</ScaffoldSection>
</ScaffoldContainer>
)
}