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 - ) => ( - - ) - )} -
- -
-
-
- ) -}) - -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 - ) => ( - - ) - )} - -
-
- ) -}) - -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 ( - ( -
- - code.replace(/svg/, 'svg class="m-auto text-color-inherit"') - } - /> -
- )} - > -
-

{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 -
-
- {_localState.formState.serviceHeaders.value.map( - (serviceHeader: { name: string; value: string }) => ( -
- - -
- ) - )} -
-
-

- 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 -
-
- {_localState.formState.serviceParams.value.map( - (serviceParam: { name: string; value: string }) => ( -
- - -
- ) - )} -
-
- - )} - - ) : ( - <> -
- -
- -
-
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={ +
+ + +
+ } + > +
+ {() => { + return ( +
+ General} + > + + + + + + + Select which table and events will trigger your function hook +

+ } + > + Conditions to fire hook + + } + > + + + + --- + + {tables.map((table) => ( + +
+

{table.name}

+

{table.schema}

+
+
+ ))} +
+ + {HOOK_EVENTS.map((event) => ( + onUpdateSelectedEvents(event.value)} + /> + ))} + +
+
+ + + Hook configuration + + } + > + + + {AVAILABLE_WEBHOOK_TYPES.map((webhook) => ( + + +
+
+

{webhook.label}

+ {webhook.disabled ? ( + Coming soon + ) : ( + Alpha + )} +
+

{webhook.description}

+
+
+ } + /> + ))} + + + + + + + setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' })) + } + onUpdateHeader={(idx, property, value) => + setHttpHeaders( + httpHeaders.map((header, i) => { + if (idx === i) return { ...header, [property]: value } + else return header + }) + ) + } + onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))} + onAddParameter={() => + setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' })) + } + onUpdateParameter={(idx, property, value) => + setHttpParameters( + httpParameters.map((param, i) => { + if (idx === i) return { ...param, [property]: value } + else return param + }) + ) + } + onRemoveParameter={(idx) => + setHttpParameters(httpParameters.filter((_, i) => idx !== i)) + } + /> + +
+ ) + }} + + + 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} + > + +
+ {httpHeaders.map((header, idx: number) => ( +
+ onUpdateHeader(idx, 'name', event.target.value)} + /> + onUpdateHeader(idx, 'value', event.target.value)} + /> +
+ ))} +
+ +
+
+
+
+ + HTTP Parameters} + > + +
+ {httpParameters.map((parameter, idx: number) => ( +
+ onUpdateParameter(idx, 'name', event.target.value)} + /> + onUpdateParameter(idx, 'value', event.target.value)} + /> +
+ ))} +
+ +
+
+
+
+ + ) +} + +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 ( -
- -

Loading hooks...

-
- ) - } - - 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)} + /> +
+ + + + + + + + + + {!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 (
-
+
schema

{schema}

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'