From f148f6a62d5b6e88d5c042935d250600f7a9e32f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 20 Mar 2023 15:19:08 +0800 Subject: [PATCH 01/14] Add RQ for database triggers and webnhooks --- .../Database/Hooks/HooksList/HookList.tsx | 19 +++-- .../Database/Hooks/HooksList/HooksList.tsx | 23 +++-- .../interfaces/Database/Tables/TableList.tsx | 11 ++- .../RowEditor/JsonEditor/JsonEditor.tsx | 2 +- .../layouts/DatabaseLayout/DatabaseLayout.tsx | 3 +- .../JsonEditor => ui}/TwoOptionToggle.tsx | 18 ++-- .../database-trigger-create-mutation.ts | 55 ++++++++++++ .../database-trigger-update-mutation.ts | 60 +++++++++++++ .../database-triggers-query.ts | 85 +++++++++++++++++++ studio/data/database-triggers/keys.ts | 5 ++ 10 files changed, 259 insertions(+), 22 deletions(-) rename studio/components/{interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor => ui}/TwoOptionToggle.tsx (74%) create mode 100644 studio/data/database-triggers/database-trigger-create-mutation.ts create mode 100644 studio/data/database-triggers/database-trigger-update-mutation.ts create mode 100644 studio/data/database-triggers/database-triggers-query.ts create mode 100644 studio/data/database-triggers/keys.ts 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..bfd9c95b97335 100644 --- a/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx +++ b/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx @@ -10,6 +10,9 @@ import SchemaTable from './SchemaTable' import AlphaPreview from 'components/to-be-cleaned/AlphaPreview' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query' + function isHooksEnabled(schemas: any): boolean { return schemas.some((schema: any) => schema.name === 'supabase_functions') } @@ -24,9 +27,19 @@ const HooksList: FC = ({ enableHooks = () => {}, }) => { const { meta } = useStore() - const hooks = meta.hooks.list() const schemas = meta.schemas.list() - const filteredHooks = hooks.filter((x: any) => + + const { project } = useProjectContext() + const { + data: hooks, + isLoading, + isError, + } = useDatabaseHooks({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const filteredHooks = (hooks || []).filter((x: any) => includes(x.name.toLowerCase(), filterString.toLowerCase()) ) const filteredHookSchemas = lodashMap(uniqBy(filteredHooks, 'schema'), 'schema') @@ -55,7 +68,7 @@ const HooksList: FC = ({ ) } - if (meta.hooks.isLoading) { + if (isLoading) { return (
@@ -64,7 +77,7 @@ const HooksList: FC = ({ ) } - if (meta.hooks.hasError) { + if (isError) { return (

Error connecting to API

@@ -75,7 +88,7 @@ const HooksList: FC = ({ return ( <> - {hooks.length == 0 ? ( + {(hooks || []).length == 0 ? (
= ({ 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/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/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 useTableRowCreateMutation = ({ + 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-update-mutation.ts b/studio/data/database-triggers/database-trigger-update-mutation.ts new file mode 100644 index 0000000000000..4ea811bd85c6e --- /dev/null +++ b/studio/data/database-triggers/database-trigger-update-mutation.ts @@ -0,0 +1,60 @@ +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 +} + +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 }) + 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, id } = variables + + await Promise.all([ + queryClient.invalidateQueries(databaseTriggerKeys.list(projectRef)), + // queryClient.invalidateQueries(databaseTriggerKeys.resource(projectRef, id)), + ]) + + 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, +} From d41c0678c85678510bc895f8715565870ef1cc2b Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 20 Mar 2023 15:25:57 +0800 Subject: [PATCH 02/14] Update database roles page to follow similar layout as wrappers and backups --- .../interfaces/Database/Roles/RolesList.tsx | 8 +-- .../Database/Roles/RolesSettings.tsx | 72 ------------------- .../components/interfaces/Database/index.ts | 2 - .../project/[ref]/database/roles/index.tsx | 21 ++---- 4 files changed, 7 insertions(+), 96 deletions(-) delete mode 100644 studio/components/interfaces/Database/Roles/RolesSettings.tsx diff --git a/studio/components/interfaces/Database/Roles/RolesList.tsx b/studio/components/interfaces/Database/Roles/RolesList.tsx index c5059c26e05bc..fd7a2acfd87c3 100644 --- a/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -1,5 +1,5 @@ import { partition } from 'lodash' -import { FC, useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { observer } from 'mobx-react-lite' import * as Tooltip from '@radix-ui/react-tooltip' import { PostgresRole } from '@supabase/postgres-meta' @@ -14,11 +14,7 @@ import { SUPABASE_ROLES } from './Roles.constants' import CreateRolePanel from './CreateRolePanel' import DeleteRoleModal from './DeleteRoleModal' -interface Props { - onSelectRole: (role: any) => void -} - -const RolesList: FC = ({}) => { +const RolesList = ({}) => { const { meta } = useStore() const [maxConnectionLimit, setMaxConnectionLimit] = useState(0) 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/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/pages/project/[ref]/database/roles/index.tsx b/studio/pages/project/[ref]/database/roles/index.tsx index 9057f0cfa8e4f..4a85543cf2f32 100644 --- a/studio/pages/project/[ref]/database/roles/index.tsx +++ b/studio/pages/project/[ref]/database/roles/index.tsx @@ -1,25 +1,14 @@ -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 { FormsContainer } from 'components/ui/Forms' const DatabaseRoles: NextPageWithLayout = () => { - const [selectedRole, setSelectedRole] = useState() - return ( -
- {isUndefined(selectedRole) ? ( - - ) : ( - setSelectedRole(undefined)} - /> - )} -
+ + + ) } From 67ac7e52aa5ca7d317e785f09bc5dde628f0541b Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 20 Mar 2023 15:46:03 +0800 Subject: [PATCH 03/14] Slight update to hooks layout --- .../Database/Hooks/HooksList/HooksList.tsx | 71 ++++++++++++------- .../Database/Hooks/HooksList/SchemaTable.tsx | 4 +- .../interfaces/Database/Roles/RolesList.tsx | 2 +- .../[ref]/database/backups/scheduled.tsx | 55 +++++++------- .../project/[ref]/database/hooks/index.tsx | 10 ++- .../project/[ref]/database/roles/index.tsx | 11 ++- .../project/[ref]/database/wrappers/index.tsx | 1 + 7 files changed, 93 insertions(+), 61 deletions(-) diff --git a/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx b/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx index bfd9c95b97335..3e1f489da7c47 100644 --- a/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx +++ b/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx @@ -1,8 +1,9 @@ import { FC } from 'react' +import Link from 'next/link' 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, IconLoader, IconExternalLink } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useStore, checkPermissions } from 'hooks' @@ -12,6 +13,7 @@ import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query' +import { FormHeader } from 'components/ui/Forms' function isHooksEnabled(schemas: any): boolean { return schemas.some((schema: any) => schema.name === 'supabase_functions') @@ -88,6 +90,12 @@ const HooksList: FC = ({ return ( <> +
+ +
{(hooks || []).length == 0 ? (
= ({
) : ( -
-
+
+
} value={filterString} onChange={(e) => setFilterString(e.target.value)} /> - - - - - {!canCreateWebhooks && ( - - -
- - You need additional permissions to create webhooks - -
-
- )} -
+
+ + + + + + + + + + {!canCreateWebhooks && ( + + +
+ + You need additional permissions to create webhooks + +
+
+ )} +
+
{filteredHooks.length <= 0 && ( -
+

No results match your filter query