| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- import { safeSql } from '@supabase/pg-meta'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { Search } from 'lucide-react'
- import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs'
- import { useEffect, useRef, useState } from 'react'
- import { toast } from 'sonner'
- import {
- AiIconAnimation,
- Button,
- Card,
- Table,
- TableBody,
- TableHead,
- TableHeader,
- TableRow,
- } from 'ui'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
- import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning'
- import FunctionList from './FunctionList'
- import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
- import { CreateFunction } from '@/components/interfaces/Database/Functions/CreateFunction'
- import {
- ReportsSelectFilter,
- selectFilterSchema,
- } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState'
- import AlertError from '@/components/ui/AlertError'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import SchemaSelector from '@/components/ui/SchemaSelector'
- import { Shortcut } from '@/components/ui/Shortcut'
- import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
- import { useDatabaseFunctionDeleteMutation } from '@/data/database-functions/database-functions-delete-mutation'
- import type { SavedDatabaseFunction } from '@/data/database-functions/database-functions-query'
- import { useDatabaseFunctionsQuery } from '@/data/database-functions/database-functions-query'
- 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 { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
- import { onSearchInputEscape } from '@/lib/keyboard'
- import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
- import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useShortcut } from '@/state/shortcuts/useShortcut'
- import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
- const createFunctionSnippet = safeSql`create function function_name()
- returns void
- language plpgsql
- as $$
- begin
- -- Write your function logic here
- end;
- $$;`
- export const FunctionsList = () => {
- 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()
- 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: SavedDatabaseFunction) => {
- 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: SavedDatabaseFunction) => {
- setSelectedFunctionIdToDuplicate(null)
- if (isInlineEditorEnabled) {
- setEditorPanelValue(fn.complete_statement)
- setEditorPanelTemplates([])
- openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
- } else {
- setSelectedFunctionToEdit(fn.id.toString())
- }
- }
- const [filterString, setFilterString] = useQueryState(
- 'search',
- parseAsString.withDefault('').withOptions({ clearOnDefault: true })
- )
- // Filters
- const [returnTypeFilter, setReturnTypeFilter] = useQueryState(
- 'return_type',
- parseAsJson(selectFilterSchema.parse)
- )
- const [securityFilter, setSecurityFilter] = useQueryState(
- 'security',
- parseAsJson(selectFilterSchema.parse)
- )
- const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
- const searchInputRef = useRef<HTMLInputElement>(null)
- const { can: canCreateFunctions } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- 'functions'
- )
- const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
- const canAddFunctions = canCreateFunctions && !isSchemaLocked
- useShortcut(
- SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
- () => {
- searchInputRef.current?.focus()
- searchInputRef.current?.select()
- },
- { label: 'Search functions' }
- )
- useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
- setFilterString('')
- setReturnTypeFilter(null)
- setSecurityFilter(null)
- })
- // [Joshen] This is to preload the data for the Schema Selector
- useSchemasQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- const {
- data: functions = [],
- error,
- isPending: isLoading,
- isError,
- isSuccess,
- } = useDatabaseFunctionsQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- // Get unique return types from functions in the selected schema
- const schemaFunctions = functions.filter((fn) => fn.schema === selectedSchema)
- const uniqueReturnTypes = Array.from(new Set(schemaFunctions.map((fn) => fn.return_type))).sort()
- // Get security options based on what exists in the selected schema
- const hasDefiner = schemaFunctions.some((fn) => fn.security_definer)
- const hasInvoker = schemaFunctions.some((fn) => !fn.security_definer)
- const securityOptions = [
- ...(hasDefiner ? [{ label: 'Definer', value: 'definer' }] : []),
- ...(hasInvoker ? [{ label: 'Invoker', value: 'invoker' }] : []),
- ]
- const [showCreateFunctionForm, setShowCreateFunctionForm] = useQueryState(
- 'new',
- parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
- )
- const [functionIdToEdit, setSelectedFunctionToEdit] = useQueryState('edit', parseAsString)
- const functionToEdit = functions.find((fn) => fn.id.toString() === functionIdToEdit)
- const [functionIdToDuplicate, setSelectedFunctionIdToDuplicate] = useQueryState(
- 'duplicate',
- parseAsString
- )
- const functionToDuplicate = functions.find((fn) => fn.id.toString() === functionIdToDuplicate)
- const [functionIdToDelete, setSelectedFunctionToDelete] = useQueryState('delete', parseAsString)
- const functionToDelete = functions.find((fn) => fn.id.toString() === functionIdToDelete)
- const {
- mutate: deleteDatabaseFunction,
- isPending: isDeletingFunction,
- isSuccess: isSuccessDelete,
- } = useDatabaseFunctionDeleteMutation({
- onSuccess: (_, variables) => {
- toast.success(`Successfully removed function ${variables.func.name}`)
- setSelectedFunctionToDelete(null)
- },
- })
- const onDeleteFunction = () => {
- if (!project) return console.error('Project is required')
- if (!functionToDelete) return console.error('Function is required')
- deleteDatabaseFunction({
- func: functionToDelete,
- projectRef: project.ref,
- connectionString: project.connectionString,
- })
- }
- useEffect(() => {
- if (isSuccess && !!functionIdToEdit && !functionToEdit) {
- toast('Function not found')
- setSelectedFunctionToEdit(null)
- }
- }, [functionIdToEdit, functionToEdit, isSuccess, setSelectedFunctionToEdit])
- useEffect(() => {
- if (isSuccess && !!functionIdToDuplicate && !functionToDuplicate) {
- toast('Function not found')
- setSelectedFunctionIdToDuplicate(null)
- }
- }, [functionIdToDuplicate, functionToDuplicate, isSuccess, setSelectedFunctionIdToDuplicate])
- useEffect(() => {
- if (isSuccess && !!functionIdToDelete && !functionToDelete && !isSuccessDelete) {
- toast('Function not found')
- setSelectedFunctionToDelete(null)
- }
- }, [
- functionIdToDelete,
- functionToDelete,
- isSuccess,
- isSuccessDelete,
- setSelectedFunctionToDelete,
- ])
- if (isLoading) return <GenericSkeletonLoader />
- if (isError) return <AlertError error={error} subject="Failed to retrieve database functions" />
- return (
- <>
- {(functions ?? []).length === 0 ? (
- <div className="flex h-full w-full items-center justify-center">
- <ProductEmptyState
- title="Functions"
- ctaButtonLabel="Create a new function"
- onClickCta={() => createFunction()}
- disabled={!canCreateFunctions}
- disabledMessage="You need additional permissions to create functions"
- >
- <p className="text-sm text-foreground-light">
- PostgreSQL functions are a set of SQL and procedural commands such as declarations,
- assignments, loops, flow-of-control, etc.
- </p>
- <p className="text-sm text-foreground-light">
- It's stored on the database server and can be invoked using the SQL interface.
- </p>
- </ProductEmptyState>
- </div>
- ) : (
- <div className="w-full space-y-4">
- <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
- <div className="flex flex-col lg:flex-row lg:items-center gap-2">
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
- onTrigger={() => setSchemaSelectorOpen(true)}
- side="bottom"
- tooltipOpen={schemaSelectorOpen ? false : undefined}
- >
- <SchemaSelector
- className="w-full lg:w-[180px]"
- size="tiny"
- showError={false}
- selectedSchemaName={selectedSchema}
- onSelectSchema={(schema) => {
- setFilterString('')
- setSelectedSchema(schema)
- }}
- open={schemaSelectorOpen}
- onOpenChange={setSchemaSelectorOpen}
- />
- </Shortcut>
- <Input
- ref={searchInputRef}
- placeholder="Search for a function"
- size="tiny"
- icon={<Search />}
- value={filterString}
- className="w-full lg:w-52"
- onChange={(e) => setFilterString(e.target.value)}
- onKeyDown={onSearchInputEscape(filterString, setFilterString)}
- />
- <ReportsSelectFilter
- label="Return Type"
- options={uniqueReturnTypes.map((type) => ({
- label: type,
- value: type,
- }))}
- value={returnTypeFilter ?? []}
- onChange={setReturnTypeFilter}
- showSearch
- />
- <ReportsSelectFilter
- label="Security"
- options={securityOptions}
- value={securityFilter ?? []}
- onChange={setSecurityFilter}
- />
- </div>
- <div className="flex items-center gap-x-2">
- {!isSchemaLocked && (
- <>
- {canAddFunctions ? (
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
- label="Create new function"
- onTrigger={() => createFunction()}
- side="bottom"
- >
- <Button className="grow" onClick={() => createFunction()}>
- Create a new function
- </Button>
- </Shortcut>
- ) : (
- <ButtonTooltip
- disabled
- className="grow"
- tooltip={{
- content: {
- side: 'bottom',
- text: 'You need additional permissions to create functions',
- },
- }}
- >
- Create a new function
- </ButtonTooltip>
- )}
- <ButtonTooltip
- type="default"
- disabled={!canCreateFunctions}
- className="px-1 pointer-events-auto"
- icon={<AiIconAnimation size={16} />}
- onClick={() => {
- openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
- aiSnap.newChat({
- name: 'Create new function',
- initialInput: `Create a new function for the schema ${selectedSchema} that does ...`,
- })
- }}
- tooltip={{
- content: {
- side: 'bottom',
- text: !canCreateFunctions
- ? 'You need additional permissions to create functions'
- : 'Create with Briven Assistant',
- },
- }}
- />
- </>
- )}
- </div>
- </div>
- {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="functions" />}
- <Card>
- <Table className="table-fixed overflow-x-auto">
- <TableHeader>
- <TableRow>
- <TableHead key="name">Name</TableHead>
- <TableHead key="arguments" className="table-cell">
- Arguments
- </TableHead>
- <TableHead key="return_type" className="table-cell">
- Return type
- </TableHead>
- <TableHead key="security" className="table-cell w-[100px]">
- Security
- </TableHead>
- <TableHead key="buttons" className="w-1/6"></TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- <FunctionList
- schema={selectedSchema}
- filterString={filterString}
- isLocked={isSchemaLocked}
- returnTypeFilter={returnTypeFilter ?? []}
- securityFilter={securityFilter ?? []}
- duplicateFunction={duplicateFunction}
- editFunction={editFunction}
- deleteFunction={(fn) => setSelectedFunctionToDelete(fn.id.toString())}
- functions={functions ?? []}
- />
- </TableBody>
- </Table>
- </Card>
- </div>
- )}
- <CreateFunction
- func={functionToEdit || functionToDuplicate}
- visible={showCreateFunctionForm || !!functionToEdit || !!functionToDuplicate}
- onClose={() => {
- setShowCreateFunctionForm(false)
- setSelectedFunctionToEdit(null)
- setSelectedFunctionIdToDuplicate(null)
- }}
- isDuplicating={!!functionToDuplicate}
- />
- <TextConfirmModal
- variant={'warning'}
- visible={!!functionToDelete}
- onCancel={() => setSelectedFunctionToDelete(null)}
- onConfirm={onDeleteFunction}
- title="Delete this function"
- loading={isDeletingFunction}
- confirmLabel={`Delete function ${functionToDelete?.name}`}
- confirmPlaceholder="Type in name of function"
- confirmString={functionToDelete?.name ?? 'Unknown'}
- text={
- <>
- <span>This will delete the function</span>{' '}
- <span className="text-bold text-foreground">{functionToDelete?.name}</span>{' '}
- <span>from the schema</span>{' '}
- <span className="text-bold text-foreground">{functionToDelete?.schema}</span>
- </>
- }
- alert={{ title: 'You cannot recover this function once deleted.' }}
- />
- </>
- )
- }
|