FunctionsList.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { safeSql } from '@supabase/pg-meta'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { Search } from 'lucide-react'
  4. import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs'
  5. import { useEffect, useRef, useState } from 'react'
  6. import { toast } from 'sonner'
  7. import {
  8. AiIconAnimation,
  9. Button,
  10. Card,
  11. Table,
  12. TableBody,
  13. TableHead,
  14. TableHeader,
  15. TableRow,
  16. } from 'ui'
  17. import { Input } from 'ui-patterns/DataInputs/Input'
  18. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  19. import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning'
  20. import FunctionList from './FunctionList'
  21. import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
  22. import { CreateFunction } from '@/components/interfaces/Database/Functions/CreateFunction'
  23. import {
  24. ReportsSelectFilter,
  25. selectFilterSchema,
  26. } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
  27. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  28. import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState'
  29. import AlertError from '@/components/ui/AlertError'
  30. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  31. import SchemaSelector from '@/components/ui/SchemaSelector'
  32. import { Shortcut } from '@/components/ui/Shortcut'
  33. import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
  34. import { useDatabaseFunctionDeleteMutation } from '@/data/database-functions/database-functions-delete-mutation'
  35. import type { SavedDatabaseFunction } from '@/data/database-functions/database-functions-query'
  36. import { useDatabaseFunctionsQuery } from '@/data/database-functions/database-functions-query'
  37. import { useSchemasQuery } from '@/data/database/schemas-query'
  38. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  39. import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
  40. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  41. import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
  42. import { onSearchInputEscape } from '@/lib/keyboard'
  43. import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
  44. import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
  45. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  46. import { useShortcut } from '@/state/shortcuts/useShortcut'
  47. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  48. const createFunctionSnippet = safeSql`create function function_name()
  49. returns void
  50. language plpgsql
  51. as $$
  52. begin
  53. -- Write your function logic here
  54. end;
  55. $$;`
  56. export const FunctionsList = () => {
  57. const { data: project } = useSelectedProjectQuery()
  58. const aiSnap = useAiAssistantStateSnapshot()
  59. const { openSidebar } = useSidebarManagerSnapshot()
  60. const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
  61. const isInlineEditorEnabled = useIsInlineEditorEnabled()
  62. const {
  63. setValue: setEditorPanelValue,
  64. setTemplates: setEditorPanelTemplates,
  65. setInitialPrompt: setEditorPanelInitialPrompt,
  66. } = useEditorPanelStateSnapshot()
  67. const createFunction = () => {
  68. setSelectedFunctionIdToDuplicate(null)
  69. if (isInlineEditorEnabled) {
  70. setEditorPanelInitialPrompt('Create a new database function that...')
  71. setEditorPanelValue(createFunctionSnippet)
  72. setEditorPanelTemplates([])
  73. openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
  74. } else {
  75. setShowCreateFunctionForm(true)
  76. }
  77. }
  78. const duplicateFunction = (fn: SavedDatabaseFunction) => {
  79. if (isInlineEditorEnabled) {
  80. const dupFn = {
  81. ...fn,
  82. name: `${fn.name}_duplicate`,
  83. }
  84. setEditorPanelInitialPrompt('Create new database function that...')
  85. setEditorPanelValue(dupFn.complete_statement)
  86. setEditorPanelTemplates([])
  87. openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
  88. } else {
  89. setSelectedFunctionIdToDuplicate(fn.id.toString())
  90. }
  91. }
  92. const editFunction = (fn: SavedDatabaseFunction) => {
  93. setSelectedFunctionIdToDuplicate(null)
  94. if (isInlineEditorEnabled) {
  95. setEditorPanelValue(fn.complete_statement)
  96. setEditorPanelTemplates([])
  97. openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
  98. } else {
  99. setSelectedFunctionToEdit(fn.id.toString())
  100. }
  101. }
  102. const [filterString, setFilterString] = useQueryState(
  103. 'search',
  104. parseAsString.withDefault('').withOptions({ clearOnDefault: true })
  105. )
  106. // Filters
  107. const [returnTypeFilter, setReturnTypeFilter] = useQueryState(
  108. 'return_type',
  109. parseAsJson(selectFilterSchema.parse)
  110. )
  111. const [securityFilter, setSecurityFilter] = useQueryState(
  112. 'security',
  113. parseAsJson(selectFilterSchema.parse)
  114. )
  115. const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
  116. const searchInputRef = useRef<HTMLInputElement>(null)
  117. const { can: canCreateFunctions } = useAsyncCheckPermissions(
  118. PermissionAction.TENANT_SQL_ADMIN_WRITE,
  119. 'functions'
  120. )
  121. const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
  122. const canAddFunctions = canCreateFunctions && !isSchemaLocked
  123. useShortcut(
  124. SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
  125. () => {
  126. searchInputRef.current?.focus()
  127. searchInputRef.current?.select()
  128. },
  129. { label: 'Search functions' }
  130. )
  131. useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
  132. setFilterString('')
  133. setReturnTypeFilter(null)
  134. setSecurityFilter(null)
  135. })
  136. // [Joshen] This is to preload the data for the Schema Selector
  137. useSchemasQuery({
  138. projectRef: project?.ref,
  139. connectionString: project?.connectionString,
  140. })
  141. const {
  142. data: functions = [],
  143. error,
  144. isPending: isLoading,
  145. isError,
  146. isSuccess,
  147. } = useDatabaseFunctionsQuery({
  148. projectRef: project?.ref,
  149. connectionString: project?.connectionString,
  150. })
  151. // Get unique return types from functions in the selected schema
  152. const schemaFunctions = functions.filter((fn) => fn.schema === selectedSchema)
  153. const uniqueReturnTypes = Array.from(new Set(schemaFunctions.map((fn) => fn.return_type))).sort()
  154. // Get security options based on what exists in the selected schema
  155. const hasDefiner = schemaFunctions.some((fn) => fn.security_definer)
  156. const hasInvoker = schemaFunctions.some((fn) => !fn.security_definer)
  157. const securityOptions = [
  158. ...(hasDefiner ? [{ label: 'Definer', value: 'definer' }] : []),
  159. ...(hasInvoker ? [{ label: 'Invoker', value: 'invoker' }] : []),
  160. ]
  161. const [showCreateFunctionForm, setShowCreateFunctionForm] = useQueryState(
  162. 'new',
  163. parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
  164. )
  165. const [functionIdToEdit, setSelectedFunctionToEdit] = useQueryState('edit', parseAsString)
  166. const functionToEdit = functions.find((fn) => fn.id.toString() === functionIdToEdit)
  167. const [functionIdToDuplicate, setSelectedFunctionIdToDuplicate] = useQueryState(
  168. 'duplicate',
  169. parseAsString
  170. )
  171. const functionToDuplicate = functions.find((fn) => fn.id.toString() === functionIdToDuplicate)
  172. const [functionIdToDelete, setSelectedFunctionToDelete] = useQueryState('delete', parseAsString)
  173. const functionToDelete = functions.find((fn) => fn.id.toString() === functionIdToDelete)
  174. const {
  175. mutate: deleteDatabaseFunction,
  176. isPending: isDeletingFunction,
  177. isSuccess: isSuccessDelete,
  178. } = useDatabaseFunctionDeleteMutation({
  179. onSuccess: (_, variables) => {
  180. toast.success(`Successfully removed function ${variables.func.name}`)
  181. setSelectedFunctionToDelete(null)
  182. },
  183. })
  184. const onDeleteFunction = () => {
  185. if (!project) return console.error('Project is required')
  186. if (!functionToDelete) return console.error('Function is required')
  187. deleteDatabaseFunction({
  188. func: functionToDelete,
  189. projectRef: project.ref,
  190. connectionString: project.connectionString,
  191. })
  192. }
  193. useEffect(() => {
  194. if (isSuccess && !!functionIdToEdit && !functionToEdit) {
  195. toast('Function not found')
  196. setSelectedFunctionToEdit(null)
  197. }
  198. }, [functionIdToEdit, functionToEdit, isSuccess, setSelectedFunctionToEdit])
  199. useEffect(() => {
  200. if (isSuccess && !!functionIdToDuplicate && !functionToDuplicate) {
  201. toast('Function not found')
  202. setSelectedFunctionIdToDuplicate(null)
  203. }
  204. }, [functionIdToDuplicate, functionToDuplicate, isSuccess, setSelectedFunctionIdToDuplicate])
  205. useEffect(() => {
  206. if (isSuccess && !!functionIdToDelete && !functionToDelete && !isSuccessDelete) {
  207. toast('Function not found')
  208. setSelectedFunctionToDelete(null)
  209. }
  210. }, [
  211. functionIdToDelete,
  212. functionToDelete,
  213. isSuccess,
  214. isSuccessDelete,
  215. setSelectedFunctionToDelete,
  216. ])
  217. if (isLoading) return <GenericSkeletonLoader />
  218. if (isError) return <AlertError error={error} subject="Failed to retrieve database functions" />
  219. return (
  220. <>
  221. {(functions ?? []).length === 0 ? (
  222. <div className="flex h-full w-full items-center justify-center">
  223. <ProductEmptyState
  224. title="Functions"
  225. ctaButtonLabel="Create a new function"
  226. onClickCta={() => createFunction()}
  227. disabled={!canCreateFunctions}
  228. disabledMessage="You need additional permissions to create functions"
  229. >
  230. <p className="text-sm text-foreground-light">
  231. PostgreSQL functions are a set of SQL and procedural commands such as declarations,
  232. assignments, loops, flow-of-control, etc.
  233. </p>
  234. <p className="text-sm text-foreground-light">
  235. It's stored on the database server and can be invoked using the SQL interface.
  236. </p>
  237. </ProductEmptyState>
  238. </div>
  239. ) : (
  240. <div className="w-full space-y-4">
  241. <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
  242. <div className="flex flex-col lg:flex-row lg:items-center gap-2">
  243. <Shortcut
  244. id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
  245. onTrigger={() => setSchemaSelectorOpen(true)}
  246. side="bottom"
  247. tooltipOpen={schemaSelectorOpen ? false : undefined}
  248. >
  249. <SchemaSelector
  250. className="w-full lg:w-[180px]"
  251. size="tiny"
  252. showError={false}
  253. selectedSchemaName={selectedSchema}
  254. onSelectSchema={(schema) => {
  255. setFilterString('')
  256. setSelectedSchema(schema)
  257. }}
  258. open={schemaSelectorOpen}
  259. onOpenChange={setSchemaSelectorOpen}
  260. />
  261. </Shortcut>
  262. <Input
  263. ref={searchInputRef}
  264. placeholder="Search for a function"
  265. size="tiny"
  266. icon={<Search />}
  267. value={filterString}
  268. className="w-full lg:w-52"
  269. onChange={(e) => setFilterString(e.target.value)}
  270. onKeyDown={onSearchInputEscape(filterString, setFilterString)}
  271. />
  272. <ReportsSelectFilter
  273. label="Return Type"
  274. options={uniqueReturnTypes.map((type) => ({
  275. label: type,
  276. value: type,
  277. }))}
  278. value={returnTypeFilter ?? []}
  279. onChange={setReturnTypeFilter}
  280. showSearch
  281. />
  282. <ReportsSelectFilter
  283. label="Security"
  284. options={securityOptions}
  285. value={securityFilter ?? []}
  286. onChange={setSecurityFilter}
  287. />
  288. </div>
  289. <div className="flex items-center gap-x-2">
  290. {!isSchemaLocked && (
  291. <>
  292. {canAddFunctions ? (
  293. <Shortcut
  294. id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
  295. label="Create new function"
  296. onTrigger={() => createFunction()}
  297. side="bottom"
  298. >
  299. <Button className="grow" onClick={() => createFunction()}>
  300. Create a new function
  301. </Button>
  302. </Shortcut>
  303. ) : (
  304. <ButtonTooltip
  305. disabled
  306. className="grow"
  307. tooltip={{
  308. content: {
  309. side: 'bottom',
  310. text: 'You need additional permissions to create functions',
  311. },
  312. }}
  313. >
  314. Create a new function
  315. </ButtonTooltip>
  316. )}
  317. <ButtonTooltip
  318. type="default"
  319. disabled={!canCreateFunctions}
  320. className="px-1 pointer-events-auto"
  321. icon={<AiIconAnimation size={16} />}
  322. onClick={() => {
  323. openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
  324. aiSnap.newChat({
  325. name: 'Create new function',
  326. initialInput: `Create a new function for the schema ${selectedSchema} that does ...`,
  327. })
  328. }}
  329. tooltip={{
  330. content: {
  331. side: 'bottom',
  332. text: !canCreateFunctions
  333. ? 'You need additional permissions to create functions'
  334. : 'Create with Briven Assistant',
  335. },
  336. }}
  337. />
  338. </>
  339. )}
  340. </div>
  341. </div>
  342. {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="functions" />}
  343. <Card>
  344. <Table className="table-fixed overflow-x-auto">
  345. <TableHeader>
  346. <TableRow>
  347. <TableHead key="name">Name</TableHead>
  348. <TableHead key="arguments" className="table-cell">
  349. Arguments
  350. </TableHead>
  351. <TableHead key="return_type" className="table-cell">
  352. Return type
  353. </TableHead>
  354. <TableHead key="security" className="table-cell w-[100px]">
  355. Security
  356. </TableHead>
  357. <TableHead key="buttons" className="w-1/6"></TableHead>
  358. </TableRow>
  359. </TableHeader>
  360. <TableBody>
  361. <FunctionList
  362. schema={selectedSchema}
  363. filterString={filterString}
  364. isLocked={isSchemaLocked}
  365. returnTypeFilter={returnTypeFilter ?? []}
  366. securityFilter={securityFilter ?? []}
  367. duplicateFunction={duplicateFunction}
  368. editFunction={editFunction}
  369. deleteFunction={(fn) => setSelectedFunctionToDelete(fn.id.toString())}
  370. functions={functions ?? []}
  371. />
  372. </TableBody>
  373. </Table>
  374. </Card>
  375. </div>
  376. )}
  377. <CreateFunction
  378. func={functionToEdit || functionToDuplicate}
  379. visible={showCreateFunctionForm || !!functionToEdit || !!functionToDuplicate}
  380. onClose={() => {
  381. setShowCreateFunctionForm(false)
  382. setSelectedFunctionToEdit(null)
  383. setSelectedFunctionIdToDuplicate(null)
  384. }}
  385. isDuplicating={!!functionToDuplicate}
  386. />
  387. <TextConfirmModal
  388. variant={'warning'}
  389. visible={!!functionToDelete}
  390. onCancel={() => setSelectedFunctionToDelete(null)}
  391. onConfirm={onDeleteFunction}
  392. title="Delete this function"
  393. loading={isDeletingFunction}
  394. confirmLabel={`Delete function ${functionToDelete?.name}`}
  395. confirmPlaceholder="Type in name of function"
  396. confirmString={functionToDelete?.name ?? 'Unknown'}
  397. text={
  398. <>
  399. <span>This will delete the function</span>{' '}
  400. <span className="text-bold text-foreground">{functionToDelete?.name}</span>{' '}
  401. <span>from the schema</span>{' '}
  402. <span className="text-bold text-foreground">{functionToDelete?.schema}</span>
  403. </>
  404. }
  405. alert={{ title: 'You cannot recover this function once deleted.' }}
  406. />
  407. </>
  408. )
  409. }