diff --git a/packages/ui/src/components/Radio/Radio.tsx b/packages/ui/src/components/Radio/Radio.tsx
index 86265df382f07..72ad375ec4392 100644
--- a/packages/ui/src/components/Radio/Radio.tsx
+++ b/packages/ui/src/components/Radio/Radio.tsx
@@ -137,7 +137,7 @@ function RadioGroup({
interface InputProps extends Omit, 'size'> {
label: string
afterLabel?: string
- beforeLabel?: string
+ beforeLabel?: string | React.ReactNode
description?: string
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
hidden?: boolean
diff --git a/studio/components/interfaces/Database/Extensions/Extensions.tsx b/studio/components/interfaces/Database/Extensions/Extensions.tsx
index 17000a747ecb4..d53fc0d302692 100644
--- a/studio/components/interfaces/Database/Extensions/Extensions.tsx
+++ b/studio/components/interfaces/Database/Extensions/Extensions.tsx
@@ -7,7 +7,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useStore, checkPermissions, useParams } from 'hooks'
import ExtensionCard from './ExtensionCard'
import { HIDDEN_EXTENSIONS } from './Extensions.constants'
-import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
+import NoSearchResults from 'components/ui/NoSearchResults'
import InformationBox from 'components/ui/InformationBox'
import Link from 'next/link'
@@ -69,7 +69,9 @@ const Extensions: FC = ({}) => {
- {extensions.length === 0 && }
+ {extensions.length === 0 && (
+ setFilterString('')} />
+ )}
{enabledExtensions.length > 0 && (
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/CheckboxEvents.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/CheckboxEvents.tsx
deleted file mode 100644
index cf4825e332627..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/CheckboxEvents.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Checkbox } from 'ui'
-import { union, without } from 'lodash'
-import { CreateHookContext } from './'
-
-const CheckboxEvents: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
- // @ts-ignore
-
{
- const temp = _localState.formState.events.value
- const value = e.target.checked
- ? union(temp, [e.target.value])
- : without(temp, e.target.value)
- _localState.onFormChange({
- key: 'events',
- value: value,
- })
- }}
- error={_localState.formState.events.error}
- labelOptional="The type of events that will trigger your function hook"
- descriptionText="These are the events that are watched by the function hook, only the events selected above will fire the function hook on the table you've selected."
- >
-
-
-
-
- )
-})
-
-export default CheckboxEvents
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/InputName.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/InputName.tsx
deleted file mode 100644
index ba2f3b9ee6170..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/InputName.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Input } from 'ui'
-import { CreateHookContext } from './'
-
-const InputName: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
-
- _localState.onFormChange({
- key: 'name',
- value: e.target.value,
- })
- }
- size="medium"
- error={_localState.formState.name.error}
- />
- )
-})
-
-export default InputName
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioGroupHookService.constants.ts b/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioGroupHookService.constants.ts
deleted file mode 100644
index 4b292673fb46e..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioGroupHookService.constants.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-export const hookServiceOptions: {
- id: string
- label: string
- badge: string
- badgeType: 'brand' | 'amber'
- description: string
- img_url: string
-}[] = [
- {
- id: 'http_request',
- label: 'HTTP Request',
- badge: 'Alpha',
- badgeType: 'brand',
- description: 'Send an HTTP request to any URL.',
- img_url: 'http-request.png',
- },
- {
- id: 'supabase_function',
- label: 'Supabase Function',
- badge: 'Coming soon',
- badgeType: 'amber',
- description: 'Choose a Supabase Function to run.',
- img_url: 'supabase-severless-function.png',
- },
- {
- id: 'google_cloud_function',
- label: 'Google cloud run',
- badge: 'Coming soon',
- badgeType: 'amber',
- description: 'Choose a google cloud function to run',
- img_url: 'google-cloud-run.png',
- },
- {
- id: 'aws_lambda_function',
- label: 'AWS Lambda',
- badge: 'Coming soon',
- badgeType: 'amber',
- description: 'Choose an AWS Lambda function to run.',
- img_url: 'aws-lambda-severless-function.png',
- },
-]
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioHookService.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioHookService.tsx
deleted file mode 100644
index a576e35ff3bb4..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioHookService.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import Image from 'next/image'
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Radio, Badge } from 'ui'
-import { BASE_PATH } from 'lib/constants'
-import { CreateHookContext } from '../'
-
-interface Props {
- id: string
- description: string
- label: string
- img_url: string
- badgeType: 'brand' | 'amber'
- badge: string
-}
-
-const RadioHookService: FC
= observer(
- ({ id, description, label, img_url, badgeType, badge }) => {
- const _localState: any = useContext(CreateHookContext)
- return (
-
-
- {/*
*/}
-
- {/*
*/}
-
-
- {label}
- {badge}
-
-
{description}
-
-
- >
- }
- />
- )
- }
-)
-
-export default RadioHookService
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/index.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/index.tsx
deleted file mode 100644
index 17afba4014abe..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/index.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Radio } from 'ui'
-
-import { CreateHookContext } from '../'
-import RadioHookService from './RadioHookService'
-import { hookServiceOptions } from './RadioGroupHookService.constants'
-
-const RadioGroupHookService: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
- <>
-
- {
- _localState.onFormChange({
- key: 'hookService',
- value: event.target.value,
- })
- }}
- value={_localState.formState.hookService.value}
- error={_localState.formState.hookService.error}
- >
- {hookServiceOptions.map((x) => (
-
- ))}
-
-
- >
- )
-})
-
-export default RadioGroupHookService
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/SelectEnabledMode.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/SelectEnabledMode.tsx
deleted file mode 100644
index 7c114d7d7357d..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/SelectEnabledMode.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Select } from 'ui'
-import { CreateHookContext } from './'
-
-const SelectEnabledMode: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
-
- )
-})
-
-export default SelectEnabledMode
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceHeaders.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceHeaders.tsx
deleted file mode 100644
index 84be5e4695b09..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceHeaders.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Button, IconPlus } from 'ui'
-
-import { CreateHookContext } from '../'
-import InputServiceHeader from './InputServiceHeader'
-
-const InputMultiServiceHeaders: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
-
- function onAddArgument() {
- _localState.onFormArrayChange({
- key: 'serviceHeaders',
- value: { name: '', value: '' },
- operation: 'add',
- })
- }
-
- return (
-
-
-
HTTP Headers
-
-
- {_localState.formState.serviceHeaders.value.map(
- (
- x: { name: string; value: string; error?: { name?: string; value?: string } },
- idx: number
- ) => (
-
- )
- )}
-
- } onClick={onAddArgument}>
- Add a new header
-
-
-
-
- )
-})
-
-export default InputMultiServiceHeaders
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceParams.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceParams.tsx
deleted file mode 100644
index 576a1cfd83ee3..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceParams.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Button, IconPlus } from 'ui'
-
-import { CreateHookContext } from '../'
-import InputServiceParam from './InputServiceParam'
-
-const InputMultiServiceParams: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
-
- function onAddArgument() {
- _localState.onFormArrayChange({
- key: 'serviceParams',
- value: { name: '', value: '' },
- operation: 'add',
- })
- }
-
- return (
-
-
-
HTTP Params
-
-
- {_localState.formState.serviceParams.value.map(
- (
- x: { name: string; value: string; error?: { name?: string; value?: string } },
- idx: number
- ) => (
-
- )
- )}
- } onClick={onAddArgument}>
- Add a new param
-
-
-
- )
-})
-
-export default InputMultiServiceParams
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceHeader.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceHeader.tsx
deleted file mode 100644
index d9e587b4a48b1..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceHeader.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { FC, useContext, FormEvent } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Input, IconTrash, Button } from 'ui'
-import { CreateHookContext } from '../'
-
-interface Props {
- idx: number
- name: string
- value: string
- error?: { name?: string; value?: string }
-}
-
-const InputServiceHeader: FC = observer(({ idx, name, value, error }) => {
- const _localState: any = useContext(CreateHookContext)
-
- function onNameChange(e: FormEvent) {
- const _value = e.currentTarget.value
- _localState.onFormArrayChange({
- key: 'serviceHeaders',
- value: { name: _value, value },
- idx,
- operation: 'update',
- })
- }
-
- function onValueChange(e: FormEvent) {
- const _value = e.currentTarget.value
- _localState.onFormArrayChange({
- key: 'serviceHeaders',
- value: { name, value: _value },
- idx,
- operation: 'update',
- })
- }
-
- function onDelete() {
- _localState.onFormArrayChange({
- key: 'serviceHeaders',
- idx,
- operation: 'delete',
- })
- }
-
- return (
-
- )
-})
-
-export default InputServiceHeader
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceParam.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceParam.tsx
deleted file mode 100644
index 4045966d5ba73..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceParam.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { FC, useContext, FormEvent } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Input, IconTrash, Button } from 'ui'
-import { CreateHookContext } from '../'
-
-interface Props {
- idx: number
- name: string
- value: string
- error?: { name?: string; value?: string }
-}
-
-const InputServiceParam: FC = observer(({ idx, name, value, error }) => {
- const _localState: any = useContext(CreateHookContext)
-
- function onNameChange(e: FormEvent) {
- const _value = e.currentTarget.value
- _localState.onFormArrayChange({
- key: 'serviceParams',
- value: { name: _value, value },
- idx,
- operation: 'update',
- })
- }
-
- function onValueChange(e: FormEvent) {
- const _value = e.currentTarget.value
- _localState.onFormArrayChange({
- key: 'serviceParams',
- value: { name, value: _value },
- idx,
- operation: 'update',
- })
- }
-
- function onDelete() {
- _localState.onFormArrayChange({
- key: 'serviceParams',
- idx,
- operation: 'delete',
- })
- }
-
- return (
-
- )
-})
-
-export default InputServiceParam
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceTimeout.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceTimeout.tsx
deleted file mode 100644
index ae255fb0be652..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceTimeout.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Input } from 'ui'
-import { CreateHookContext } from '../'
-
-// Not used currently
-
-const InputServiceTimeout: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
-
- _localState.onFormChange({
- key: 'serviceTimeoutMs',
- value: e.target.value,
- })
- }
- size="medium"
- error={_localState.formState.serviceTimeoutMs.error}
- />
- )
-})
-
-export default InputServiceTimeout
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceUrl.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceUrl.tsx
deleted file mode 100644
index f65aaa19efe11..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceUrl.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Input } from 'ui'
-import { CreateHookContext } from '../'
-
-const InputServiceUrl: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
-
- _localState.onFormChange({
- key: 'serviceUrl',
- value: e.target.value,
- })
- }
- size="medium"
- error={_localState.formState.serviceUrl.error}
- />
- )
-})
-
-export default InputServiceUrl
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/SelectServiceMethod.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/SelectServiceMethod.tsx
deleted file mode 100644
index fca6069eb3948..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/SelectServiceMethod.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Select } from 'ui'
-import { CreateHookContext } from '../'
-
-const SelectServiceMethod: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
-
- )
-})
-
-export default SelectServiceMethod
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/ServiceUnavailableBox.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/ServiceUnavailableBox.tsx
deleted file mode 100644
index 59e5ae128a878..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/ServiceUnavailableBox.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Alert, Button } from 'ui'
-import { CreateHookContext } from '../'
-
-const ServiceUnavailableBox: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
-
-
-
We currently do not support this service.
-
-
-
- )
-})
-
-export default ServiceUnavailableBox
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/index.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/index.tsx
deleted file mode 100644
index 7570f17070650..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/index.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { SidePanel } from 'ui'
-
-import { CreateHookContext } from '../'
-import SelectServiceMethod from './SelectServiceMethod'
-import InputServiceUrl from './InputServiceUrl'
-import InputMultiServiceHeaders from './InputMultiServiceHeaders'
-import InputMultiServiceParams from './InputMultiServiceParams'
-import ServiceUnavailableBox from './ServiceUnavailableBox'
-
-const ServiceConfigForm: FC = observer(({}) => {
- const _localState: any = useContext(CreateHookContext)
- return (
- <>
- {_localState.formState.hookService.value === 'http_request' ? (
- <>
-
-
-
-
HTTP Request
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
-
-
-
- )}
- >
- )
-})
-
-export default ServiceConfigForm
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/TableSelection.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/TableSelection.tsx
deleted file mode 100644
index 3e46504f3f952..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/TableSelection.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import Link from 'next/link'
-import SVG from 'react-inlinesvg'
-import { FC, useContext } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Input, Listbox } from 'ui'
-
-import { CreateHookContext } from './'
-import { useStore } from 'hooks'
-import { BASE_PATH } from 'lib/constants'
-
-const TableSelection: FC = observer(({}) => {
- const { ui } = useStore()
- const projectRef = ui.selectedProject?.ref
- const _localState: any = useContext(CreateHookContext)
-
- if (_localState.tables.length === 0) {
- return (
-
-
- This is the table the trigger will watch for changes. There's currently no tables
- created - please create one{' '}
-
-
- here
-
- first.
-
- }
- />
- )
- }
-
- return (
- {
- const _table = _localState.tables.find((x: any) => x.id === id)
- if (_table) {
- _localState.onFormChange({
- key: 'schema',
- value: _table.schema,
- })
- _localState.onFormChange({
- key: 'table',
- value: _table.name,
- })
- _localState.onFormChange({
- key: 'tableId',
- value: id,
- })
- }
- }}
- size="medium"
- error={_localState.formState.table.error}
- descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
- >
- {_localState.tables.map((x: any) => {
- return (
- (
-
-
- )}
- >
-
-
{x.name}
-
{x.schema}
-
-
- )
- })}
-
- )
-})
-
-export default TableSelection
diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/index.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/index.tsx
deleted file mode 100644
index 5de69a46e6152..0000000000000
--- a/studio/components/interfaces/Database/Hooks/CreateHook/index.tsx
+++ /dev/null
@@ -1,512 +0,0 @@
-import { makeAutoObservable } from 'mobx'
-import { FC, useEffect, createContext } from 'react'
-import { observer, useLocalObservable } from 'mobx-react-lite'
-import { isEmpty, mapValues, has, filter, keyBy, isUndefined } from 'lodash'
-import { Dictionary } from 'components/grid'
-import { Badge, Input, SidePanel } from 'ui'
-import { useStore } from 'hooks'
-
-import InputName from './InputName'
-import SelectEnabledMode from './SelectEnabledMode'
-import CheckboxEvents from './CheckboxEvents'
-import TableSelection from './TableSelection'
-import RadioGroupHookService from './RadioGroupHookService'
-import ServiceConfigForm from './ServiceConfigForm'
-
-import { formatArguments, convertKeyValue, hasWhitespace, isValidHttpUrl } from '../Hooks.utils'
-
-// [Joshen] TODO: Refactor to use Form component
-
-class CreateHookFormState {
- id: number | undefined
- originalName: string | undefined
- // @ts-ignore
- enabledMode: { value: string }
- // @ts-ignore
- events: { value: string[]; error?: string }
- // @ts-ignore
- name: { value: string; error?: string }
- // @ts-ignore
- schema: { value: string }
- // @ts-ignore
- table: { value: string; error?: string }
- // @ts-ignore
- tableId: { value: number; error?: string }
- // @ts-ignore
- hookService: { value: string; error?: string }
- // @ts-ignore
- serviceUrl: { value: string; error?: string }
- // @ts-ignore
- serviceMethod: { value: string; error?: string }
- // @ts-ignore
- serviceHeaders: {
- value: { name: string; value: string; error?: { name?: string; value?: string } }[]
- }
- // @ts-ignore
- serviceParams: {
- value: { name: string; value: string; error?: { name?: string; value?: string } }[]
- }
- // @ts-ignore
- serviceTimeoutMs: { value: string; error?: string }
-
- constructor() {
- makeAutoObservable(this)
- this.reset()
- }
-
- get requestBody() {
- return {
- id: this.id,
- activation: 'AFTER',
- enabled_mode: this.enabledMode.value,
- events: this.events.value,
- function_args: [
- this.serviceUrl.value.replaceAll(' ', ''),
- this.serviceMethod.value,
- JSON.stringify(mapValues(keyBy(this.serviceHeaders.value, 'name'), 'value')),
- formatArguments(this.serviceParams.value),
- this.serviceTimeoutMs.value,
- ],
- function_name: 'http_request',
- function_schema: 'supabase_functions',
- orientation: 'ROW',
- name: this.name.value,
- schema: this.schema.value,
- table: this.table.value,
- table_id: this.tableId.value,
- }
- }
-
- reset(hook?: Dictionary) {
- this.id = hook?.id
- this.originalName = hook?.name
- this.enabledMode = { value: hook?.enabled_mode ?? 'ORIGIN' }
- this.events = { value: hook?.events ?? [] }
- this.name = { value: hook?.name ?? '' }
- this.schema = { value: hook?.schema ?? '' }
- this.table = { value: hook?.table ?? '' }
- this.tableId = { value: hook?.table_id ?? '' }
- // TODO: hook obj doesn't have service field
- this.hookService = { value: 'http_request' }
- /**
- * hook.function_args contains 5 params in order
- * [url, method, headers, params, timeout]
- */
- this.serviceUrl = { value: hook?.function_args?.length == 5 ? hook?.function_args[0] : '' }
- this.serviceMethod = {
- value: hook?.function_args?.length == 5 ? hook?.function_args[1] : 'GET',
- }
- this.serviceHeaders =
- hook?.function_args?.length == 5
- ? convertKeyValue(hook?.function_args[2])
- : { value: [{ name: 'Content-type', value: 'application/json' }] }
-
- this.serviceParams =
- hook?.function_args?.length == 5 ? convertKeyValue(hook?.function_args[3]) : { value: [] }
-
- this.serviceTimeoutMs = {
- value: hook?.function_args?.length == 5 ? hook?.function_args[4] : '1000',
- }
- }
-
- update(state: Dictionary) {
- this.enabledMode = state.enabledMode
- this.events = state.events
- this.name = state.name
- this.schema = state.schema
- this.table = state.table
- this.tableId = state.tableId
- this.hookService = state.hookService
- this.serviceUrl = state.serviceUrl
- this.serviceMethod = state.serviceMethod
- this.serviceHeaders = state.serviceHeaders
- this.serviceParams = state.serviceParams
- this.serviceTimeoutMs = state.serviceTimeoutMs
- }
-}
-
-interface ICreateHookStore {
- loading: boolean
- formState: CreateHookFormState
- meta: any
- project: any
- tables: Dictionary[]
- onFormChange: (value: { key: string; value: any }) => void
- onFormArrayChange: (value: {
- operation: 'add' | 'delete' | 'update'
- key: string
- idx?: number
- value?: any
- }) => void
- setDefaultSelectedTable: () => void
- setLoading: (value: boolean) => void
- setTables: (value: Dictionary[]) => void
- validateForm: () => boolean
-}
-
-class CreateHookStore implements ICreateHookStore {
- loading = false
- formState = new CreateHookFormState()
- meta = null
- project = null
- tables = []
-
- constructor() {
- makeAutoObservable(this)
- }
-
- get title() {
- return this.formState.id
- ? `Edit '${this.formState.originalName}' database webhook`
- : 'Add a new database webhook'
- }
-
- get isEditing() {
- return this.formState.id != undefined
- }
-
- // set first table as default selection
- setDefaultSelectedTable = () => {
- if (this.tables?.length != 0) {
- this.formState.schema.value = (this.tables[0] as any).schema
- this.formState.table.value = (this.tables[0] as any).name
- this.formState.tableId.value = (this.tables[0] as any).id
- }
- }
-
- setLoading = (value: boolean) => {
- this.loading = value
- }
-
- setTables = (value: Dictionary[]) => {
- this.tables = value as any
- this.setDefaultSelectedTable()
- }
-
- onFormChange = ({ key, value }: { key: string; value: any }) => {
- if (has(this.formState, key)) {
- const temp = (this.formState as any)[key] as any
- ;(this.formState as any)[key] = { ...temp, value, error: undefined }
- } else {
- ;(this.formState as any)[key] = { value }
- }
- }
-
- onFormArrayChange = ({
- operation,
- key,
- idx,
- value,
- }: {
- operation: 'add' | 'delete' | 'update'
- key: string
- idx?: number
- value?: any
- }) => {
- switch (operation) {
- case 'add': {
- if (has(this.formState, key)) {
- ;(this.formState as any)[key].value.push(value)
- } else {
- ;(this.formState as any)[key] = { value: [value] }
- }
- break
- }
- case 'delete': {
- if (has(this.formState, key)) {
- const temp = filter(
- (this.formState as any)[key].value,
- (_: any, index: number) => index != idx
- ) as any
- ;(this.formState as any)[key].value = temp
- }
- break
- }
- default: {
- if (has(this.formState, key) && !isUndefined(idx)) {
- ;(this.formState as any)[key].value[idx] = value
- } else {
- ;(this.formState as any)[key] = { value: [{ value }] }
- }
- }
- }
- }
-
- validateForm = () => {
- let isValidated = true
- const _state = mapValues(this.formState, (x: { value: any }, key: string) => {
- switch (key) {
- case 'name': {
- if (isEmpty(x.value) || hasWhitespace(x.value)) {
- isValidated = false
- return { ...x, error: 'Invalid hook name' }
- } else {
- return x
- }
- }
- case 'events': {
- if (isEmpty(x.value)) {
- isValidated = false
- return { ...x, error: 'Select at least 1 event' }
- } else {
- return x
- }
- }
- case 'table': {
- if (isEmpty(x.value)) {
- isValidated = false
- return { ...x, error: 'You must choose a table' }
- } else {
- return x
- }
- }
- case 'hookService': {
- if (x.value != 'http_request') {
- isValidated = false
- return { ...x, error: 'Not supported service' }
- } else {
- return x
- }
- }
- case 'serviceTimeoutMs': {
- if (isEmpty(x.value) || isNaN(x.value)) {
- isValidated = false
- return { ...x, error: 'Invalid input' }
- } else {
- return x
- }
- }
- case 'serviceUrl': {
- if (isEmpty(x.value) || !isValidHttpUrl(x.value)) {
- isValidated = false
- return { ...x, error: 'Invalid input' }
- } else {
- return x
- }
- }
- case 'serviceMethod': {
- if (isEmpty(x.value)) {
- isValidated = false
- return { ...x, error: 'Invalid input' }
- } else {
- return x
- }
- }
- case 'serviceHeaders':
- case 'serviceParams': {
- const temp = x.value?.map((i: Dictionary) => {
- const error: any = { name: undefined, value: undefined }
- if (isEmpty(i.name) || hasWhitespace(i.name)) {
- isValidated = false
- error.name = 'Invalid name'
- }
- if (isEmpty(i.value)) {
- isValidated = false
- error.value = 'Missing value'
- }
- return { ...i, error }
- })
- x.value = temp
- return x
- }
- default:
- return x
- }
- })
- if (!isValidated) {
- this.formState.update(_state)
- }
- return isValidated
- }
-}
-
-export const CreateHookContext = createContext(null)
-
-interface CreateHookProps {
- hook?: any
- visible: boolean
- setVisible: (value: boolean) => void
-}
-
-const CreateHook: FC = ({ hook, visible = true, setVisible }) => {
- const { meta, ui } = useStore()
- const project = ui.selectedProject
-
- const _localState: any = useLocalObservable(() => new CreateHookStore())
- _localState.meta = meta
- _localState.project = project
-
- useEffect(() => {
- const fetchSchemas = async () => {
- await _localState.meta.tables.load()
- const tables = _localState!.meta!.tables.list(
- (table: any) => !meta.excludedSchemas.includes(table.schema)
- )
- _localState.setTables(tables)
- }
-
- fetchSchemas()
- }, [])
-
- useEffect(() => {
- if (hook) {
- _localState.formState.reset(hook)
- } else {
- _localState.formState.reset()
- _localState.setDefaultSelectedTable()
- }
- }, [visible, hook])
-
- async function handleSubmit() {
- try {
- if (_localState.validateForm()) {
- _localState.setLoading(true)
-
- const body = _localState.formState.requestBody
-
- const response: any = _localState.isEditing
- ? await _localState.meta.hooks.update(body.id, body)
- : await _localState.meta.hooks.create(body)
-
- if (response.error) {
- ui.setNotification({
- category: 'error',
- message: `Failed to create hook: ${response.error.message ?? 'Submit request failed'}`,
- })
- _localState.setLoading(false)
- } else {
- ui.setNotification({
- category: 'success',
- message: `${_localState.isEditing ? 'Updated' : 'Created new'} hook called ${
- response.name
- }`,
- })
- _localState.setLoading(false)
- setVisible(!visible)
- }
- }
- } catch (error: any) {
- ui.setNotification({ category: 'error', message: `Failed to create hook: ${error.message}` })
- _localState.setLoading(false)
- }
- }
-
- return (
- setVisible(!visible)}
- >
-
-
- {_localState.isEditing ? (
- <>
-
-
-
-
-
- {_localState.formState.serviceHeaders.value.length > 0 && (
- <>
-
-
-
-
HTTP Headers
- Read only
-
-
-
-
- Note: You can only edit a webhook's name and mode. To change
- other settings, you'll need to delete and recreate the webhook.
-
- >
- )}
-
- {_localState.formState.serviceParams.value.length > 0 && (
- <>
-
-
-
-
HTTP Parameters
- Read only
-
-
-
- >
- )}
- >
- ) : (
- <>
-
-
-
-
-
-
Conditions to fire hook
-
-
-
-
-
-
Type of hook
-
-
-
- >
- )}
-
-
-
- )
-}
-
-export default observer(CreateHook)
diff --git a/studio/components/interfaces/Database/Hooks/DeleteHook.tsx b/studio/components/interfaces/Database/Hooks/DeleteHook.tsx
deleted file mode 100644
index 14efa7f8f5b90..0000000000000
--- a/studio/components/interfaces/Database/Hooks/DeleteHook.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import * as React from 'react'
-import { observer } from 'mobx-react-lite'
-import { useStore } from 'hooks'
-import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
-
-type DeleteHookProps = {
- hook?: any
- visible: boolean
- setVisible: (value: boolean) => void
-} & any
-
-const DeleteHook: React.FC = ({ hook, visible, setVisible }) => {
- const { ui, meta } = useStore()
- const [loading, setLoading] = React.useState(false)
- const { id, name, schema } = hook ?? {}
-
- async function handleDelete() {
- try {
- setLoading(true)
- if (!id) {
- throw Error('Invalid hook info')
- }
- const response: any = await meta.hooks.del(id)
- if (response.error) {
- throw response.error
- } else {
- ui.setNotification({ category: 'success', message: `Successfully removed ${name}` })
- setVisible(false)
- }
- } catch (error: any) {
- ui.setNotification({
- category: 'error',
- message: `Failed to delete ${name}: ${error.message}`,
- })
- } finally {
- setLoading(false)
- }
- }
-
- return (
- setVisible(!visible)}
- onConfirm={handleDelete}
- title="Delete this hook"
- loading={loading}
- confirmLabel={`Delete hook ${name}`}
- confirmPlaceholder="Type in name of hook"
- confirmString={name}
- text={`This will delete your hook called ${name} of schema ${schema}.`}
- alert="You cannot recover this hook once it is deleted!"
- />
- )
-}
-
-export default observer(DeleteHook)
diff --git a/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx b/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx
new file mode 100644
index 0000000000000..36bf3bbd156ec
--- /dev/null
+++ b/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx
@@ -0,0 +1,67 @@
+import { useState } from 'react'
+import { observer } from 'mobx-react-lite'
+import { useStore } from 'hooks'
+import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useDatabaseTriggerDeleteMutation } from 'data/database-triggers/database-trigger-delete-mutation'
+import { PostgresTrigger } from '@supabase/postgres-meta'
+
+interface DeleteHookModalProps {
+ visible: boolean
+ selectedHook?: PostgresTrigger
+ onClose: () => void
+}
+
+const DeleteHookModal = ({ selectedHook, visible, onClose }: DeleteHookModalProps) => {
+ const { ui } = useStore()
+ const [loading, setLoading] = useState(false)
+ const { id, name, schema } = selectedHook ?? {}
+
+ const { project } = useProjectContext()
+ const { mutateAsync: deleteDatabaseTrigger } = useDatabaseTriggerDeleteMutation()
+
+ async function handleDelete() {
+ if (!project) {
+ return console.error('Project ref is required')
+ }
+ if (!id) {
+ return ui.setNotification({ category: 'error', message: 'Unable find selected hook' })
+ }
+
+ try {
+ setLoading(true)
+ await deleteDatabaseTrigger({
+ id,
+ projectRef: project.ref,
+ connectionString: project.connectionString,
+ })
+ ui.setNotification({ category: 'success', message: `Successfully deleted ${name}` })
+ onClose()
+ } catch (error: any) {
+ ui.setNotification({
+ category: 'error',
+ message: `Failed to delete ${name}: ${error.message}`,
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ onClose()}
+ onConfirm={handleDelete}
+ title="Delete database webhook"
+ loading={loading}
+ confirmLabel={`Delete ${name}`}
+ confirmPlaceholder="Type in name of webhook"
+ confirmString={name || ''}
+ text={`This will delete the webhook "${name}" from the schema "${schema}".`}
+ alert="You cannot recover this webhook once it is deleted!"
+ />
+ )
+}
+
+export default observer(DeleteHookModal)
diff --git a/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
new file mode 100644
index 0000000000000..583ab66294b99
--- /dev/null
+++ b/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
@@ -0,0 +1,454 @@
+import Image from 'next/image'
+import { useState, useRef, useEffect } from 'react'
+import { PostgresTrigger } from '@supabase/postgres-meta'
+import { Button, SidePanel, Form, Input, Listbox, Checkbox, Radio, Badge, Modal } from 'ui'
+
+import { useStore } from 'hooks'
+import { tryParseJson, uuidv4 } from 'lib/helpers'
+import HTTPRequestFields from './HTTPRequestFields'
+import { isValidHttpUrl } from './Hooks.utils'
+import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
+import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
+import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation'
+import ConfirmationModal from 'components/ui/ConfirmationModal'
+
+export interface EditHookPanelProps {
+ visible: boolean
+ selectedHook?: PostgresTrigger
+ onClose: () => void
+}
+
+export type HTTPArgument = { id: string; name: string; value: string }
+
+const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => {
+ // [Joshen] Need to change to use RQ once Alaister's PR goes in
+ const { meta, ui } = useStore()
+ const submitRef: any = useRef()
+ const [isEdited, setIsEdited] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isClosingPanel, setIsClosingPanel] = useState(false)
+
+ // [Joshen] There seems to be some bug between Checkbox.Group within the Form component
+ // hence why this external state as a temporary workaround
+ const [events, setEvents] = useState([])
+ const [eventsError, setEventsError] = useState()
+
+ // For HTTP request
+ const [httpHeaders, setHttpHeaders] = useState([])
+ const [httpParameters, setHttpParameters] = useState([])
+
+ const { project } = useProjectContext()
+ const { mutateAsync: createDatabaseTrigger } = useDatabaseTriggerCreateMutation()
+ const { mutateAsync: updateDatabaseTrigger } = useDatabaseTriggerUpdateMutation()
+
+ const tables = meta.tables.list()
+ const initialValues = {
+ name: selectedHook?.name ?? '',
+ table_id: selectedHook?.table_id ?? '',
+ function_name: selectedHook?.function_name ?? 'http_request',
+ http_url: selectedHook?.function_args?.[0] ?? '',
+ http_method: selectedHook?.function_args?.[1] ?? 'GET',
+ }
+
+ useEffect(() => {
+ if (visible) {
+ setIsEdited(false)
+ setIsSubmitting(false)
+ setIsClosingPanel(false)
+
+ // Reset form fields outside of the Form context
+ if (selectedHook !== undefined) {
+ setEvents(selectedHook.events)
+
+ const [url, method, headers, parameters] = selectedHook.function_args
+ const formattedHeaders = tryParseJson(headers) || {}
+ setHttpHeaders(
+ Object.keys(formattedHeaders).map((key) => {
+ return { id: uuidv4(), name: key, value: formattedHeaders[key] }
+ })
+ )
+ const formattedParameters = tryParseJson(parameters) || {}
+ setHttpParameters(
+ Object.keys(formattedParameters).map((key) => {
+ return { id: uuidv4(), name: key, value: formattedParameters[key] }
+ })
+ )
+ } else {
+ setEvents([])
+ setHttpHeaders([{ id: uuidv4(), name: 'Content-type', value: 'application/json' }])
+ setHttpParameters([{ id: uuidv4(), name: '', value: '' }])
+ }
+ }
+ }, [visible, selectedHook])
+
+ const onClosePanel = () => {
+ if (isEdited) setIsClosingPanel(true)
+ else onClose()
+ }
+
+ const onUpdateSelectedEvents = (event: string) => {
+ if (events.includes(event)) {
+ setEvents(events.filter((e) => e !== event))
+ } else {
+ setEvents(events.concat(event))
+ }
+ setEventsError(undefined)
+ }
+
+ const validate = (values: any) => {
+ const errors: any = {}
+
+ if (!values.name) {
+ errors['name'] = 'Please provide a name for your webhook'
+ }
+ if (!values.table_id) {
+ errors['table_id'] = 'Please select a table for which your webhook will trigger from'
+ }
+
+ if (values.function_name === 'http_request') {
+ // For HTTP requests
+ if (!values.http_url) {
+ errors['http_url'] = 'Please provide a URL'
+ } else if (!values.http_url.startsWith('http')) {
+ errors['http_url'] = 'Please include HTTP/HTTPs to your URL'
+ } else if (!isValidHttpUrl(values.http_url)) {
+ errors['http_url'] = 'Please provide a valid URL'
+ }
+ } else if (values.function_name === 'supabase_function') {
+ // For Supabase Edge Functions
+ }
+
+ if (JSON.stringify(values) !== JSON.stringify(initialValues)) setIsEdited(true)
+ return errors
+ }
+
+ const onSubmit = async (values: any) => {
+ if (!project?.ref) {
+ return console.error('Project ref is required')
+ }
+ if (events.length === 0) {
+ return setEventsError('Please select at least one event')
+ }
+
+ const selectedTable = meta.tables.byId(values.table_id)
+ if (!selectedTable) {
+ return ui.setNotification({ category: 'error', message: 'Unable to find selected table' })
+ }
+
+ const payload: any = {
+ events,
+ activation: 'AFTER',
+ orientation: 'ROW',
+ enabled_mode: 'ORIGIN',
+ name: values.name,
+ table: selectedTable.name,
+ schema: selectedTable.schema,
+ table_id: values.table_id,
+ function_name: values.function_name,
+ function_schema: 'supabase_functions',
+ function_args: [],
+ }
+
+ if (values.function_name === 'http_request') {
+ const serviceTimeoutMs = '1000'
+ const headers = httpHeaders
+ .filter((header) => header.name && header.value)
+ .reduce((a: any, b: any) => {
+ a[b.name] = b.value
+ return a
+ }, {})
+ const parameters = httpParameters
+ .filter((param) => param.name && param.value)
+ .reduce((a: any, b: any) => {
+ a[b.name] = b.value
+ return a
+ }, {})
+ payload.function_args = [
+ values.http_url,
+ values.http_method,
+ JSON.stringify(headers),
+ JSON.stringify(parameters),
+ serviceTimeoutMs,
+ ]
+ } else if (values.function_name === 'supabase_function') {
+ payload.function_args = []
+ }
+
+ if (selectedHook === undefined) {
+ try {
+ setIsSubmitting(true)
+ await createDatabaseTrigger({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ payload,
+ })
+ ui.setNotification({
+ category: 'success',
+ message: `Successfully created new webhook "${values.name}"`,
+ })
+ onClose()
+ } catch (error: any) {
+ ui.setNotification({
+ error,
+ category: 'error',
+ message: `Failed to create webhook: ${error.message}`,
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ } else {
+ try {
+ setIsSubmitting(true)
+ await updateDatabaseTrigger({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ originalTrigger: selectedHook,
+ updatedTrigger: payload,
+ })
+ ui.setNotification({
+ category: 'success',
+ message: `Successfully updated webhook "${values.name}"`,
+ })
+ onClose()
+ } catch (error: any) {
+ ui.setNotification({
+ error,
+ category: 'error',
+ message: `Failed to update webhook: ${error.message}`,
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+ }
+
+ return (
+ <>
+
+ Update webhook {selectedHook.name}
+ >
+ )
+ }
+ className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out"
+ onConfirm={() => {}}
+ onCancel={() => onClosePanel()}
+ customFooter={
+
+
+
+
+ }
+ >
+
+ )
+ }}
+
+
+ setIsClosingPanel(false)}
+ onSelectConfirm={() => {
+ setIsClosingPanel(false)
+ setIsEdited(false)
+ onClose()
+ }}
+ children={
+
+
+ There are unsaved changes. Are you sure you want to close the panel? Your changes will
+ be lost.
+
+
+ }
+ />
+ >
+ )
+}
+
+export default EditHookPanel
diff --git a/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx
new file mode 100644
index 0000000000000..8f5c9a241c5a0
--- /dev/null
+++ b/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx
@@ -0,0 +1,131 @@
+import { Button, Listbox, Input, SidePanel, IconTrash, IconPlus } from 'ui'
+import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms'
+import { HTTPArgument } from './EditHookPanel'
+
+interface HTTPRequestFieldsProps {
+ httpHeaders: HTTPArgument[]
+ httpParameters: HTTPArgument[]
+ onAddHeader: () => void
+ onUpdateHeader: (idx: number, property: string, value: string) => void
+ onRemoveHeader: (idx: number) => void
+ onAddParameter: () => void
+ onUpdateParameter: (idx: number, property: string, value: string) => void
+ onRemoveParameter: (idx: number) => void
+}
+
+const HTTPRequestFields = ({
+ httpHeaders = [],
+ httpParameters = [],
+ onAddHeader,
+ onUpdateHeader,
+ onRemoveHeader,
+ onAddParameter,
+ onUpdateParameter,
+ onRemoveParameter,
+}: HTTPRequestFieldsProps) => {
+ return (
+ <>
+ HTTP Request}
+ >
+
+
+
+ GET
+
+
+ POST
+
+
+
+
+
+
+ HTTP Headers}
+ >
+
+
+
+
+
+ HTTP Parameters}
+ >
+
+
+
+
+ >
+ )
+}
+
+export default HTTPRequestFields
diff --git a/studio/components/interfaces/Database/Hooks/Hooks.constants.ts b/studio/components/interfaces/Database/Hooks/Hooks.constants.ts
new file mode 100644
index 0000000000000..5e7287a0874ef
--- /dev/null
+++ b/studio/components/interfaces/Database/Hooks/Hooks.constants.ts
@@ -0,0 +1,51 @@
+import { BASE_PATH } from 'lib/constants'
+
+export const ENABLED_MODE_OPTIONS = [
+ { label: 'Origin', value: 'ORIGIN', description: 'This is the default behaviour' },
+ {
+ label: 'Replica',
+ value: 'REPLICA',
+ description: 'Will only fire if the session is in “replica” mode',
+ },
+ {
+ label: 'Always',
+ value: 'ALWAYS',
+ description: 'Will fire regardless of the current replication role',
+ },
+ { label: 'Disabled', value: 'DISABLED', description: 'Will not fire' },
+]
+
+export const HOOK_EVENTS = [
+ {
+ label: 'Insert',
+ value: 'INSERT',
+ description: 'Any insert operation on the table',
+ },
+ {
+ label: 'Update',
+ value: 'UPDATE',
+ description: 'Any update operation, of any column in the table',
+ },
+ {
+ label: 'Delete',
+ value: 'DELETE',
+ description: 'Any deletion of a record',
+ },
+]
+
+export const AVAILABLE_WEBHOOK_TYPES = [
+ {
+ disabled: false,
+ value: 'http_request',
+ icon: `${BASE_PATH}/img/function-providers/http-request.png`,
+ label: 'HTTP Request',
+ description: 'Send an HTTP request to any URL.',
+ },
+ {
+ disabled: true,
+ value: 'supabase_function',
+ icon: `${BASE_PATH}/img/function-providers/supabase-severless-function.png`,
+ label: 'Supabase Edge Functions',
+ description: 'Choose a Supabase edge function to run.',
+ },
+]
diff --git a/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx b/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx
index e28a7e373d359..62b75c07cf0c1 100644
--- a/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx
+++ b/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx
@@ -5,8 +5,10 @@ import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Badge, Button, Dropdown, IconMoreVertical, IconTrash, IconEdit3 } from 'ui'
-import { checkPermissions, useStore } from 'hooks'
+import { checkPermissions } from 'hooks'
import Table from 'components/to-be-cleaned/Table'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query'
interface Props {
schema: string
@@ -21,12 +23,15 @@ const HookList: FC = ({
editHook = () => {},
deleteHook = () => {},
}) => {
- const { meta } = useStore()
- const hooks = meta.hooks.list()
- const filteredHooks = hooks.filter((x: any) =>
- includes(x.name.toLowerCase(), filterString.toLowerCase())
+ const { project } = useProjectContext()
+ const { data: hooks } = useDatabaseHooks({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+
+ const filteredHooks = (hooks ?? []).filter(
+ (x: any) => includes(x.name.toLowerCase(), filterString.toLowerCase()) && x.schema === schema
)
- const _hooks = filteredHooks.filter((x: any) => x.schema == schema)
const canUpdateWebhook = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
function onEdit(trigger: any) {
@@ -39,7 +44,7 @@ const HookList: FC = ({
return (
<>
- {_hooks.map((x: any) => (
+ {filteredHooks.map((x: any) => (
{x.name}
diff --git a/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx b/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx
index 87dd212199b3c..e1274b1b09771 100644
--- a/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx
+++ b/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx
@@ -1,81 +1,65 @@
-import { FC } from 'react'
+import Link from 'next/link'
+import { useState } from 'react'
import { includes, uniqBy, map as lodashMap } from 'lodash'
import { observer } from 'mobx-react-lite'
import * as Tooltip from '@radix-ui/react-tooltip'
-import { Button, Input, IconSearch, IconLoader } from 'ui'
+import { Button, Input, IconSearch, IconExternalLink } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useStore, checkPermissions } from 'hooks'
+import { checkPermissions } from 'hooks'
import SchemaTable from './SchemaTable'
import AlphaPreview from 'components/to-be-cleaned/AlphaPreview'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
-function isHooksEnabled(schemas: any): boolean {
- return schemas.some((schema: any) => schema.name === 'supabase_functions')
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query'
+import { FormHeader } from 'components/ui/Forms'
+import ShimmeringLoader from 'components/ui/ShimmeringLoader'
+import ClientLoadingError from 'components/ui/ClientLoadingError'
+import NoSearchResults from 'components/ui/NoSearchResults'
+
+export interface HooksListProps {
+ createHook: () => void
+ editHook: (hook: any) => void
+ deleteHook: (hook: any) => void
}
-const HooksList: FC = ({
- hooksEnabled,
- filterString,
- setFilterString = () => {},
+const HooksList = ({
createHook = () => {},
editHook = () => {},
deleteHook = () => {},
- enableHooks = () => {},
-}) => {
- const { meta } = useStore()
- const hooks = meta.hooks.list()
- const schemas = meta.schemas.list()
- const filteredHooks = hooks.filter((x: any) =>
+}: HooksListProps) => {
+ const { project } = useProjectContext()
+ const {
+ data: hooks,
+ isLoading,
+ isError,
+ } = useDatabaseHooks({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+ const [filterString, setFilterString] = useState('')
+
+ const filteredHooks = (hooks || []).filter((x: any) =>
includes(x.name.toLowerCase(), filterString.toLowerCase())
)
const filteredHookSchemas = lodashMap(uniqBy(filteredHooks, 'schema'), 'schema')
const canCreateWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
- const isEnabled = hooksEnabled || isHooksEnabled(schemas)
-
- if (!isEnabled) {
- return (
- <>
- {schemas.length === 0 ? (
- Checking if hooks are enabled...
- ) : (
- enableHooks()}
- disabled={!canCreateWebhooks}
- disabledMessage="You need additional permissions to enable hooks"
- >
-
- Enable hooks on your project.
-
- )}
- >
- )
- }
-
- if (meta.hooks.isLoading) {
- return (
-
- )
- }
-
- if (meta.hooks.hasError) {
- return (
-
-
Error connecting to API
- {`${meta.hooks.error?.message ?? 'Unknown error'}`}
-
- )
- }
-
return (
<>
- {hooks.length == 0 ? (
+ {isLoading ? (
+
+
+
+
+
+ ) : isError ? (
+
+ ) : (hooks || []).length == 0 ? (
) : (
-
-
-
}
- value={filterString}
- onChange={(e) => setFilterString(e.target.value)}
+ <>
+
+
-
-
-
-
- {!canCreateWebhooks && (
-
-
-
-
- You need additional permissions to create webhooks
-
-
-
- )}
-
- {filteredHooks.length <= 0 && (
-
-
No results match your filter query
-
+
+
+
}
+ value={filterString}
+ onChange={(e) => setFilterString(e.target.value)}
+ />
+
+
+
+ }>
+ Documentation
+
+
+
+
+
+
+
+ {!canCreateWebhooks && (
+
+
+
+
+ You need additional permissions to create webhooks
+
+
+
+ )}
+
+
- )}
- {filteredHookSchemas.map((schema: any) => (
-
- ))}
-
+ {filteredHooks.length <= 0 && (
+
setFilterString('')}
+ />
+ )}
+ {filteredHookSchemas.map((schema: any) => (
+
+ ))}
+
+ >
)}
>
)
diff --git a/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx b/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx
index fd8f1927aac14..ff2b229556de3 100644
--- a/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx
+++ b/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx
@@ -20,13 +20,13 @@ const SchemaTable: FC
= ({
return (
-
diff --git a/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/studio/components/interfaces/Database/Publications/PublicationsList.tsx
index 082a40cf158cc..2969c3ced58b5 100644
--- a/studio/components/interfaces/Database/Publications/PublicationsList.tsx
+++ b/studio/components/interfaces/Database/Publications/PublicationsList.tsx
@@ -6,7 +6,7 @@ import { Button, Input, Toggle, IconSearch, IconAlertCircle } from 'ui'
import { checkPermissions, useStore } from 'hooks'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
import Table from 'components/to-be-cleaned/Table'
-import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
+import NoSearchResults from 'components/ui/NoSearchResults'
import InformationBox from 'components/ui/InformationBox'
interface Props {
@@ -83,7 +83,7 @@ const PublicationsList: FC = ({ onSelectPublication = () => {} }) => {
{publications.length === 0 ? (
-
+ setFilterString('')} />
) : (
void
-}
-
-const RolesList: FC = ({}) => {
+const RolesList = ({}) => {
const { meta } = useStore()
const [maxConnectionLimit, setMaxConnectionLimit] = useState(0)
@@ -66,7 +63,7 @@ const RolesList: FC = ({}) => {
/>
-
+
= ({}) => {
{filterString.length > 0 && filteredRoles.length === 0 && (
-
-
No results found
-
- Your search for "{filterString}" did not return any results
-
-
+
setFilterString('')} />
)}
diff --git a/studio/components/interfaces/Database/Roles/RolesSettings.tsx b/studio/components/interfaces/Database/Roles/RolesSettings.tsx
deleted file mode 100644
index 2ff4c1da8a60d..0000000000000
--- a/studio/components/interfaces/Database/Roles/RolesSettings.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { FC } from 'react'
-import { observer } from 'mobx-react-lite'
-import { Button, IconChevronLeft } from 'ui'
-import Divider from 'components/ui/Divider'
-
-import Panel from 'components/ui/Panel'
-
-interface Props {
- selectedRole: any
- onSelectBack: () => void
-}
-
-const RolesSettings: FC
= ({ selectedRole, onSelectBack = () => {} }) => {
- return (
- <>
-
-
-
-
-
-
-
-
- Super user
- {selectedRole.is_superuser ? 'true' : 'false'}
-
-
-
- User can login
- {selectedRole.can_login ? 'true' : 'false'}
-
-
-
- User can create databases
- {selectedRole.can_create_db ? 'true' : 'false'}
-
-
-
-
- User can initiate streaming replication and put the system in and out of backup mode
-
- {selectedRole.is_replication_role ? 'true' : 'false'}
-
-
-
- User bypasses every row level security policy
- {selectedRole.can_bypass_rls ? 'true' : 'false'}
-
-
- >
- )
-}
-
-export default observer(RolesSettings)
diff --git a/studio/components/interfaces/Database/Tables/TableList.tsx b/studio/components/interfaces/Database/Tables/TableList.tsx
index b058f9ce6f4b7..7838160107906 100644
--- a/studio/components/interfaces/Database/Tables/TableList.tsx
+++ b/studio/components/interfaces/Database/Tables/TableList.tsx
@@ -20,6 +20,7 @@ import { useStore, checkPermissions } from 'hooks'
import Table from 'components/to-be-cleaned/Table'
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
import type { PostgresTable, PostgresSchema } from '@supabase/postgres-meta'
+import ShimmeringLoader from 'components/ui/ShimmeringLoader'
interface Props {
selectedSchema: string
@@ -39,6 +40,7 @@ const TableList: FC = ({
onOpenTable = () => {},
}) => {
const { meta } = useStore()
+ const { isLoading } = meta.tables
const [filterString, setFilterString] = useState('')
const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
@@ -150,7 +152,14 @@ const TableList: FC = ({
)}
- {tables.length === 0 ? (
+
+ {isLoading ? (
+
+
+
+
+
+ ) : tables.length === 0 ? (
) : (
diff --git a/studio/components/interfaces/Database/index.ts b/studio/components/interfaces/Database/index.ts
index 94c664c2854d7..f2dabff52f776 100644
--- a/studio/components/interfaces/Database/index.ts
+++ b/studio/components/interfaces/Database/index.ts
@@ -2,7 +2,6 @@ import TableList from './Tables/TableList'
import ColumnList from './Tables/ColumnList'
import RolesList from './Roles/RolesList'
-import RolesSettings from './Roles/RolesSettings'
import Extensions from './Extensions/Extensions'
@@ -27,7 +26,6 @@ export {
TableList,
ColumnList,
RolesList,
- RolesSettings,
Extensions,
Wrappers,
CreateWrapper,
diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx
index ee2af2392280f..9e0329af84d16 100644
--- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx
+++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx
@@ -3,7 +3,7 @@ import { SidePanel } from 'ui'
import { useStore } from 'hooks'
import JsonEditor from './JsonCodeEditor'
-import TwoOptionToggle from './TwoOptionToggle'
+import TwoOptionToggle from 'components/ui/TwoOptionToggle'
import DrilldownViewer from './DrilldownViewer'
import ActionBar from '../../ActionBar'
import { minifyJSON, prettifyJSON, tryParseJson } from 'lib/helpers'
diff --git a/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx
index f7fa11e4234b6..5b45d0b9064c7 100644
--- a/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx
+++ b/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx
@@ -16,7 +16,8 @@ interface Props {
const DatabaseLayout: FC
= ({ title, children }) => {
const { meta, ui, vault, backups } = useStore()
- const { isInitialized, isLoading, error } = meta.tables
+ const { isLoading } = meta.schemas
+ const { isInitialized, error } = meta.tables
const project = ui.selectedProject
const router = useRouter()
diff --git a/studio/components/to-be-cleaned/NoSearchResults.tsx b/studio/components/to-be-cleaned/NoSearchResults.tsx
index ba03cab88239c..632833f3563a1 100644
--- a/studio/components/to-be-cleaned/NoSearchResults.tsx
+++ b/studio/components/to-be-cleaned/NoSearchResults.tsx
@@ -1,6 +1,8 @@
import SVG from 'react-inlinesvg'
import { BASE_PATH } from 'lib/constants'
+// [Joshen] To be deprecated in favor of NoSearchResults in components/ui
+
export const NoSearchResults = () => {
return (
diff --git a/studio/components/ui/ClientLoadingError.tsx b/studio/components/ui/ClientLoadingError.tsx
new file mode 100644
index 0000000000000..d9da0fe8039d1
--- /dev/null
+++ b/studio/components/ui/ClientLoadingError.tsx
@@ -0,0 +1,34 @@
+import Link from 'next/link'
+import { Button, IconAlertCircle } from 'ui'
+
+export interface ClientLoadingErrorProps {
+ projectRef: string
+ description: string
+}
+
+const ClientLoadingError = ({
+ projectRef,
+ description = 'An error has occured',
+}: ClientLoadingErrorProps) => {
+ return (
+
+
+
+
+
{description}
+
+ Try refreshing your browser. However, if this issue persists, please reach out to us via
+ support.
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ClientLoadingError
diff --git a/studio/components/ui/NoSearchResults.tsx b/studio/components/ui/NoSearchResults.tsx
new file mode 100644
index 0000000000000..54e94c8277282
--- /dev/null
+++ b/studio/components/ui/NoSearchResults.tsx
@@ -0,0 +1,26 @@
+import { Button } from 'ui'
+
+export interface NoSearchResultsProps {
+ searchString: string
+ onResetFilter?: () => void
+}
+
+const NoSearchResults = ({ searchString, onResetFilter }: NoSearchResultsProps) => {
+ return (
+
+
+
No results found
+
+ Your search for "{searchString}" did not return any results
+
+
+ {onResetFilter !== undefined && (
+
+ )}
+
+ )
+}
+
+export default NoSearchResults
diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/TwoOptionToggle.tsx b/studio/components/ui/TwoOptionToggle.tsx
similarity index 74%
rename from studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/TwoOptionToggle.tsx
rename to studio/components/ui/TwoOptionToggle.tsx
index 44aba26ed7a06..24292b0ad8e5a 100644
--- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/TwoOptionToggle.tsx
+++ b/studio/components/ui/TwoOptionToggle.tsx
@@ -1,7 +1,9 @@
+import clsx from 'clsx'
import { FC } from 'react'
interface Props {
options: any
+ width?: number
activeOption: any
onClickOption: any
borderOverride: string
@@ -9,6 +11,7 @@ interface Props {
const TwoOptionToggle: FC
= ({
options,
+ width = 50,
activeOption,
onClickOption,
borderOverride = 'border-gray-600 dark:border-gray-800',
@@ -18,25 +21,26 @@ const TwoOptionToggle: FC = ({
) => `absolute top-0 z-1 text-xs inline-flex h-full items-center justify-center font-medium
${
isActive ? 'hover:text-white' : 'hover:text-gray-600'
- } dark:hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-100
+ } dark:hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue
transition ease-in-out duration-150`
return (
{options.map((option: any, index: number) => (
>
+
+export const useDatabaseTriggerCreateMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => createDatabaseTrigger(vars),
+ {
+ async onSuccess(data, variables, context) {
+ const { projectRef } = variables
+ await queryClient.invalidateQueries(databaseTriggerKeys.list(projectRef))
+ await onSuccess?.(data, variables, context)
+ },
+ ...options,
+ }
+ )
+}
diff --git a/studio/data/database-triggers/database-trigger-delete-mutation.ts b/studio/data/database-triggers/database-trigger-delete-mutation.ts
new file mode 100644
index 0000000000000..05bbcfdc40246
--- /dev/null
+++ b/studio/data/database-triggers/database-trigger-delete-mutation.ts
@@ -0,0 +1,56 @@
+import { PostgresTrigger } from '@supabase/postgres-meta'
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { delete_ } from 'lib/common/fetch'
+import { API_URL } from 'lib/constants'
+import { databaseTriggerKeys } from './keys'
+
+export type DatabaseTriggerDeleteVariables = {
+ id: number
+ projectRef: string
+ connectionString?: string
+}
+
+type DeleteDatabaseTriggerResponse = PostgresTrigger & { error?: any }
+
+export async function deleteDatabaseTrigger({
+ id,
+ projectRef,
+ connectionString,
+}: DatabaseTriggerDeleteVariables) {
+ if (!projectRef) throw new Error('projectRef is required')
+ if (!connectionString) throw new Error('connectionString is required')
+
+ let headers = new Headers()
+ headers.set('x-connection-encrypted', connectionString)
+
+ const response = (await delete_(`${API_URL}/pg-meta/${projectRef}/triggers?id=${id}`, undefined, {
+ headers: Object.fromEntries(headers),
+ })) as DeleteDatabaseTriggerResponse
+
+ if (response?.error) throw response.error
+ return response as PostgresTrigger
+}
+
+type DatabaseTriggerDeleteData = Awaited>
+
+export const useDatabaseTriggerDeleteMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => deleteDatabaseTrigger(vars),
+ {
+ async onSuccess(data, variables, context) {
+ const { projectRef } = variables
+ await queryClient.invalidateQueries(databaseTriggerKeys.list(projectRef))
+ await onSuccess?.(data, variables, context)
+ },
+ ...options,
+ }
+ )
+}
diff --git a/studio/data/database-triggers/database-trigger-update-mutation.ts b/studio/data/database-triggers/database-trigger-update-mutation.ts
new file mode 100644
index 0000000000000..d2fa02786d260
--- /dev/null
+++ b/studio/data/database-triggers/database-trigger-update-mutation.ts
@@ -0,0 +1,59 @@
+import { PostgresTrigger } from '@supabase/postgres-meta'
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { patch } from 'lib/common/fetch'
+import { API_URL } from 'lib/constants'
+import { databaseTriggerKeys } from './keys'
+
+export type DatabaseTriggerUpdateVariables = {
+ id: number
+ projectRef: string
+ connectionString?: string
+ payload: any
+}
+
+type UpdateDatabaseTriggerResponse = PostgresTrigger & { error?: any }
+
+export async function updateDatabaseTrigger({
+ id,
+ projectRef,
+ connectionString,
+ payload,
+}: DatabaseTriggerUpdateVariables) {
+ if (!id) throw new Error('id is required')
+ if (!projectRef) throw new Error('projectRef is required')
+ if (!connectionString) throw new Error('connectionString is required')
+
+ let headers = new Headers()
+ headers.set('x-connection-encrypted', connectionString)
+
+ const response = (await patch(`${API_URL}/pg-meta/${projectRef}/triggers?id=${id}`, payload, {
+ headers: Object.fromEntries(headers),
+ })) as UpdateDatabaseTriggerResponse
+
+ if (response.error) throw response.error
+ return response as PostgresTrigger
+}
+
+type DatabaseTriggerUpdateData = Awaited>
+
+export const useDatabaseTriggerUpdateMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => updateDatabaseTrigger(vars),
+ {
+ async onSuccess(data, variables, context) {
+ const { projectRef } = variables
+ await queryClient.invalidateQueries(databaseTriggerKeys.list(projectRef))
+ await onSuccess?.(data, variables, context)
+ },
+ ...options,
+ }
+ )
+}
diff --git a/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts b/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts
new file mode 100644
index 0000000000000..9665237532cd1
--- /dev/null
+++ b/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts
@@ -0,0 +1,66 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { executeSql } from 'data/sql/execute-sql-query'
+import { databaseTriggerKeys } from './keys'
+import { quoteLiteral } from 'lib/pg-format'
+
+// [Joshen] Writing this query within FE as the PATCH endpoint from pg-meta only supports updating
+// trigger name and enabled mode. So we'll delete and create the trigger, within a single transaction
+// Copying the SQL from https://github.com/supabase/postgres-meta/blob/master/src/lib/PostgresMetaTriggers.ts
+
+export type DatabaseTriggerUpdateVariables = {
+ projectRef: string
+ connectionString?: string
+ originalTrigger: any
+ updatedTrigger: any
+}
+
+export function getDatabaseTriggerUpdateSQL({
+ originalTrigger,
+ updatedTrigger,
+}: Pick) {
+ const { name, activation, events, schema, table, function_schema, function_name, function_args } =
+ updatedTrigger
+ return `
+BEGIN;
+DROP TRIGGER "${originalTrigger.name}" ON "${originalTrigger.schema}"."${originalTrigger.table}";
+CREATE TRIGGER "${name}" ${activation} ${events.join(' OR ')} ON "${schema}"."${table}"
+ FOR EACH ROW EXECUTE FUNCTION
+ "${function_schema}"."${function_name}"(${function_args?.map(quoteLiteral).join(',') ?? ''});
+COMMIT;
+`.trim()
+}
+
+export async function updateDatabaseTrigger({
+ projectRef,
+ connectionString,
+ originalTrigger,
+ updatedTrigger,
+}: DatabaseTriggerUpdateVariables) {
+ const sql = getDatabaseTriggerUpdateSQL({ originalTrigger, updatedTrigger })
+ const { result } = await executeSql({ projectRef, connectionString, sql })
+ return result
+}
+
+type DatabaseTriggerUpdateTxnData = Awaited>
+
+export const useDatabaseTriggerUpdateMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => updateDatabaseTrigger(vars),
+ {
+ async onSuccess(data, variables, context) {
+ const { projectRef } = variables
+ await queryClient.invalidateQueries(databaseTriggerKeys.list(projectRef))
+ await onSuccess?.(data, variables, context)
+ },
+ ...options,
+ }
+ )
+}
diff --git a/studio/data/database-triggers/database-triggers-query.ts b/studio/data/database-triggers/database-triggers-query.ts
new file mode 100644
index 0000000000000..3d23f004b3e9a
--- /dev/null
+++ b/studio/data/database-triggers/database-triggers-query.ts
@@ -0,0 +1,85 @@
+import { PostgresTrigger } from '@supabase/postgres-meta'
+import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
+import { get } from 'lib/common/fetch'
+import { API_URL } from 'lib/constants'
+import { useCallback } from 'react'
+import { databaseTriggerKeys } from './keys'
+
+export type DatabaseTriggersVariables = {
+ projectRef?: string
+ connectionString?: string
+}
+
+export async function getDatabaseTriggers(
+ { projectRef, connectionString }: DatabaseTriggersVariables,
+ signal?: AbortSignal
+) {
+ if (!projectRef) throw new Error('projectRef is required')
+ if (!connectionString) throw new Error('connectionString is required')
+
+ let headers = new Headers()
+ headers.set('x-connection-encrypted', connectionString)
+
+ const response = (await get(`${API_URL}/pg-meta/${projectRef}/triggers`, {
+ headers: Object.fromEntries(headers),
+ signal,
+ })) as PostgresTrigger[] | { error?: any }
+
+ if (!Array.isArray(response) && response.error) throw response.error
+ return response as PostgresTrigger[]
+}
+
+export type DatabaseTriggersData = Awaited>
+export type DatabaseTriggersError = unknown
+
+export const useDatabaseHooks = (
+ { projectRef, connectionString }: DatabaseTriggersVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) =>
+ useQuery(
+ databaseTriggerKeys.list(projectRef),
+ ({ signal }) => getDatabaseTriggers({ projectRef, connectionString }, signal),
+ {
+ // @ts-ignore
+ select(data) {
+ return (data as PostgresTrigger[]).filter(
+ (trigger) => trigger.function_schema === 'supabase_functions' && trigger.schema !== 'net'
+ )
+ },
+ enabled:
+ enabled && typeof projectRef !== 'undefined' && typeof connectionString !== 'undefined',
+ ...options,
+ }
+ )
+
+export const useDatabaseTriggers = (
+ { projectRef, connectionString }: DatabaseTriggersVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) =>
+ useQuery(
+ databaseTriggerKeys.list(projectRef),
+ ({ signal }) => getDatabaseTriggers({ projectRef, connectionString }, signal),
+ {
+ enabled:
+ enabled && typeof projectRef !== 'undefined' && typeof connectionString !== 'undefined',
+ ...options,
+ }
+ )
+
+export const useDatabaseTriggersPrefetch = ({ projectRef }: DatabaseTriggersVariables) => {
+ const client = useQueryClient()
+
+ return useCallback(() => {
+ if (projectRef) {
+ client.prefetchQuery(databaseTriggerKeys.list(projectRef), ({ signal }) =>
+ getDatabaseTriggers({ projectRef }, signal)
+ )
+ }
+ }, [projectRef])
+}
diff --git a/studio/data/database-triggers/keys.ts b/studio/data/database-triggers/keys.ts
new file mode 100644
index 0000000000000..a81a321c4331b
--- /dev/null
+++ b/studio/data/database-triggers/keys.ts
@@ -0,0 +1,5 @@
+export const databaseTriggerKeys = {
+ list: (projectRef: string | undefined) => ['projects', projectRef, 'database-triggers'] as const,
+ resource: (projectRef: string | undefined, id: string | undefined) =>
+ ['projects', projectRef, 'resources', id] as const,
+}
diff --git a/studio/lib/pg-format.ts b/studio/lib/pg-format.ts
new file mode 100644
index 0000000000000..fcc7bc055c087
--- /dev/null
+++ b/studio/lib/pg-format.ts
@@ -0,0 +1,77 @@
+// [Joshen] These are from https://github.com/datalanche/node-pg-format/blob/master/lib/index.js
+function formatDate(date: any) {
+ date = date.replace('T', ' ')
+ date = date.replace('Z', '+00')
+ return date
+}
+
+function arrayToList(useSpace: any, array: any, formatter: any) {
+ let sql = ''
+
+ sql += useSpace ? ' (' : '('
+ for (var i = 0; i < array.length; i++) {
+ sql += (i === 0 ? '' : ', ') + formatter(array[i])
+ }
+ sql += ')'
+
+ return sql
+}
+
+export function quoteLiteral(value: any): any {
+ let literal = null
+ let explicitCast = null
+
+ if (value === undefined || value === null) {
+ return 'NULL'
+ } else if (value === false) {
+ return "'f'"
+ } else if (value === true) {
+ return "'t'"
+ } else if (value instanceof Date) {
+ return "'" + formatDate(value.toISOString()) + "'"
+ } else if (value instanceof Buffer) {
+ return "E'\\\\x" + value.toString('hex') + "'"
+ } else if (Array.isArray(value) === true) {
+ let temp = []
+ for (let i = 0; i < value.length; i++) {
+ if (Array.isArray(value[i]) === true) {
+ temp.push(arrayToList(i !== 0, value[i], quoteLiteral))
+ } else {
+ temp.push(quoteLiteral(value[i]))
+ }
+ }
+ return temp.toString()
+ } else if (value === Object(value)) {
+ explicitCast = 'jsonb'
+ literal = JSON.stringify(value)
+ } else {
+ literal = value.toString().slice(0) // create copy
+ }
+
+ let hasBackslash = false
+ let quoted = "'"
+
+ for (let i = 0; i < literal.length; i++) {
+ let c = literal[i]
+ if (c === "'") {
+ quoted += c + c
+ } else if (c === '\\') {
+ quoted += c + c
+ hasBackslash = true
+ } else {
+ quoted += c
+ }
+ }
+
+ quoted += "'"
+
+ if (hasBackslash === true) {
+ quoted = 'E' + quoted
+ }
+
+ if (explicitCast) {
+ quoted += '::' + explicitCast
+ }
+
+ return quoted
+}
diff --git a/studio/pages/project/[ref]/database/backups/scheduled.tsx b/studio/pages/project/[ref]/database/backups/scheduled.tsx
index 18cd5658a7b76..71694a0c630b0 100644
--- a/studio/pages/project/[ref]/database/backups/scheduled.tsx
+++ b/studio/pages/project/[ref]/database/backups/scheduled.tsx
@@ -8,6 +8,7 @@ import { checkPermissions, useStore } from 'hooks'
import { DatabaseLayout } from 'components/layouts'
import { BackupsList } from 'components/interfaces/Database'
import NoPermission from 'components/ui/NoPermission'
+import { FormsContainer } from 'components/ui/Forms'
const DatabaseScheduledBackups: NextPageWithLayout = () => {
const { ui } = useStore()
@@ -17,33 +18,35 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
const canReadScheduledBackups = checkPermissions(PermissionAction.READ, 'back_ups')
return (
-
-
Backups
-
-
{
- if (id === 'pitr') router.push(`/project/${ref}/database/backups/pitr`)
- }}
- >
-
-
-
-
-
-
- Projects are backed up daily around midnight of your project's region and can be restored
- at any time.
-
- {canReadScheduledBackups ? (
-
- ) : (
-
- )}
+
+
+
Backups
+
+
{
+ if (id === 'pitr') router.push(`/project/${ref}/database/backups/pitr`)
+ }}
+ >
+
+
+
+
+
+
+ Projects are backed up daily around midnight of your project's region and can be
+ restored at any time.
+
+ {canReadScheduledBackups ? (
+
+ ) : (
+
+ )}
+
-
+
)
}
diff --git a/studio/pages/project/[ref]/database/hooks/index.tsx b/studio/pages/project/[ref]/database/hooks/index.tsx
index 67f796cd86687..9b96f0f9390ac 100644
--- a/studio/pages/project/[ref]/database/hooks/index.tsx
+++ b/studio/pages/project/[ref]/database/hooks/index.tsx
@@ -1,3 +1,4 @@
+import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
@@ -9,43 +10,46 @@ import { API_URL } from 'lib/constants'
import { post } from 'lib/common/fetch'
import { DatabaseLayout } from 'components/layouts'
import NoPermission from 'components/ui/NoPermission'
-import CreateHook from 'components/interfaces/Database/Hooks/CreateHook'
-import DeleteHook from 'components/interfaces/Database/Hooks/DeleteHook'
import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList'
+import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal'
+import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel'
+import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
+import AlphaPreview from 'components/to-be-cleaned/AlphaPreview'
+import { IconLoader } from 'ui'
const HooksPage: NextPageWithLayout = () => {
const { meta, ui } = useStore()
const router = useRouter()
const { ref } = router.query
+ const schemas = meta.schemas.list()
+ const { isLoading: isLoadingSchemas } = meta.schemas
- const [hooksEnabled, setHooksEnabled] = useState
(false)
- const [filterString, setFilterString] = useState('')
const [selectedHook, setSelectedHook] = useState()
const [showCreateHookForm, setShowCreateHookForm] = useState(false)
const [showDeleteHookForm, setShowDeleteHookForm] = useState(false)
+ const isHooksEnabled = schemas.some((schema: any) => schema.name === 'supabase_functions')
const canReadWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers')
+ const canCreateWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
useEffect(() => {
- if (ui.selectedProject?.ref) {
- fetchHooks()
- }
+ if (ui.selectedProject?.ref) meta.hooks.load()
}, [ui.selectedProject?.ref])
- const fetchHooks = async () => {
- meta.hooks.load()
- }
-
const enableHooksForProject = async () => {
- const headers: any = {}
- const connectionString = ui.selectedProject?.connectionString
- if (connectionString) headers['x-connection-encrypted'] = connectionString
- try {
- await post(`${API_URL}/database/${ref}/hook-enable`, {})
- setHooksEnabled(true)
- } catch (error) {
- console.error(error)
+ const res = await post(`${API_URL}/database/${ref}/hook-enable`, {})
+ if (res.error) {
+ ui.setNotification({
+ category: 'error',
+ message: `Failed to enable webhooks: ${res.error.message}`,
+ })
+ } else {
+ meta.schemas.load()
+ ui.setNotification({
+ category: 'success',
+ message: `Successfully enabled webhooks`,
+ })
}
}
@@ -68,28 +72,56 @@ const HooksPage: NextPageWithLayout = () => {
return
}
+ if (isLoadingSchemas) {
+ return (
+
+
+
Checking if hooks are enabled
+
+ )
+ }
+
+ if (!isHooksEnabled) {
+ return (
+
+
enableHooksForProject()}
+ disabled={!canCreateWebhooks}
+ disabledMessage="You need additional permissions to enable webhooks"
+ >
+
+
+ Database Webhooks can be used to trigger serverless functions or send requests to an
+ HTTP endpoint.
+
+ Enable database webhooks on your project.
+
+
+ )
+ }
+
return (
- <>
-
-
+
+ setShowCreateHookForm(false)}
/>
- setShowDeleteHookForm(false)}
/>
- >
+
)
}
diff --git a/studio/pages/project/[ref]/database/roles/index.tsx b/studio/pages/project/[ref]/database/roles/index.tsx
index 9057f0cfa8e4f..ef584cc1668d8 100644
--- a/studio/pages/project/[ref]/database/roles/index.tsx
+++ b/studio/pages/project/[ref]/database/roles/index.tsx
@@ -1,24 +1,18 @@
-import { useState } from 'react'
import { observer } from 'mobx-react-lite'
-import { isUndefined } from 'lodash'
-
import { DatabaseLayout } from 'components/layouts'
-import { RolesList, RolesSettings } from 'components/interfaces/Database'
+import { RolesList } from 'components/interfaces/Database'
import { NextPageWithLayout } from 'types'
+import clsx from 'clsx'
const DatabaseRoles: NextPageWithLayout = () => {
- const [selectedRole, setSelectedRole] = useState()
-
return (
-
- {isUndefined(selectedRole) ? (
-
- ) : (
-
setSelectedRole(undefined)}
- />
+
+
)
}
diff --git a/studio/pages/project/[ref]/database/wrappers/index.tsx b/studio/pages/project/[ref]/database/wrappers/index.tsx
index d8df5feae3467..c5454dff1ffc6 100644
--- a/studio/pages/project/[ref]/database/wrappers/index.tsx
+++ b/studio/pages/project/[ref]/database/wrappers/index.tsx
@@ -1,3 +1,4 @@
+import clsx from 'clsx'
import { observer } from 'mobx-react-lite'
import { PermissionAction } from '@supabase/shared-types/out/constants'