From 1efffc9ee82be5502b319dbe80fe3dcfdf4f72d7 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 9 Jan 2023 16:52:39 +0800 Subject: [PATCH] Implement FDW update flow --- .../Database/Wrappers/CreateWrapper.tsx | 99 +---- .../Database/Wrappers/EditWrapper.tsx | 348 ++++++++++++++++++ .../Database/Wrappers/InputField.tsx | 94 +++++ .../Database/Wrappers/WrapperRow.tsx | 23 +- .../Database/Wrappers/Wrappers.utils.ts | 21 ++ .../components/interfaces/Database/index.ts | 2 + .../Vault/Secrets/SecretsManagement.tsx | 6 +- .../layouts/DatabaseLayout/DatabaseLayout.tsx | 3 +- studio/data/fdw/fdw-create-mutation.ts | 14 +- studio/data/fdw/fdws-query.ts | 11 +- .../project/[ref]/database/wrappers/[id].tsx | 26 ++ 11 files changed, 530 insertions(+), 117 deletions(-) create mode 100644 studio/components/interfaces/Database/Wrappers/EditWrapper.tsx create mode 100644 studio/components/interfaces/Database/Wrappers/InputField.tsx create mode 100644 studio/pages/project/[ref]/database/wrappers/[id].tsx diff --git a/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx b/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx index 1cb0eb0a55d..a0e8a64fd50 100644 --- a/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx +++ b/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx @@ -1,23 +1,11 @@ import Link from 'next/link' import { isEmpty } from 'lodash' -import { FC, useState } from 'react' +import { useState } from 'react' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' -import { - Button, - Form, - Input, - IconArrowLeft, - IconExternalLink, - IconHelpCircle, - IconEdit, - IconEye, - IconEyeOff, - IconTrash, -} from 'ui' +import { Button, Form, Input, IconArrowLeft, IconExternalLink, IconEdit, IconTrash } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ServerOption } from './Wrappers.types' import { checkPermissions, useParams, useStore } from 'hooks' import { FormPanel, @@ -28,88 +16,14 @@ import { } from 'components/ui/Forms' import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import InputField from './InputField' import { WRAPPERS } from './Wrappers.constants' import WrapperTableEditor from './WrapperTableEditor' import { makeValidateRequired } from './Wrappers.utils' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -const InputField: FC<{ option: ServerOption; error: any }> = ({ option, error }) => { - const [showHidden, setShowHidden] = useState(!option.hidden) - if (option.isTextArea) { - return ( -
- -

{option.label}

- {option.urlHelper !== undefined && ( - - - - - - )} -
- } - defaultValue={option.defaultValue ?? ''} - required={option.required ?? false} - error={error} - className="input-mono" - rows={6} - /> - - ) - } else { - return ( - -

{option.label}

- {option.urlHelper !== undefined && ( - - - - - - )} - - } - defaultValue={option.defaultValue ?? ''} - error={error} - className={`${option.name === 'server_name' ? '' : 'input-mono'}`} - type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'} - actions={ - option.hidden ? ( -
-
- ) : null - } - /> - ) - } -} - const CreateWrapper = () => { - const formId = 'test-form' + const formId = 'create-wrapper-form' const router = useRouter() const { ui } = useStore() const { ref, type } = useParams() @@ -127,7 +41,7 @@ const CreateWrapper = () => { const initialValues = wrapperMeta !== undefined ? { - name: '', + wrapper_name: '', server_name: '', ...Object.fromEntries( wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? '']) @@ -171,7 +85,7 @@ const CreateWrapper = () => { connectionString: project?.connectionString, wrapper: wrapperMeta, formState: { ...values, server_name: `${wrapper_name}_server` }, - newTables, + tables: newTables, }) ui.setNotification({ category: 'success', @@ -270,6 +184,7 @@ const CreateWrapper = () => { ))} diff --git a/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx b/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx new file mode 100644 index 00000000000..c5ebac4f210 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx @@ -0,0 +1,348 @@ +import Link from 'next/link' +import { isEmpty } from 'lodash' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { Button, Form, Input, IconArrowLeft, IconExternalLink, IconEdit, IconTrash } from 'ui' +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { VaultSecret } from 'types' +import { checkPermissions, useParams, useStore } from 'hooks' +import { useFDWsQuery } from 'data/fdw/fdws-query' +import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' + +import InputField from './InputField' +import { WRAPPERS } from './Wrappers.constants' +import WrapperTableEditor from './WrapperTableEditor' +import { + formatWrapperTables, + makeValidateRequired, + convertKVStringArrayToJson, +} from './Wrappers.utils' +import Loading from 'components/ui/Loading' +import { + FormPanel, + FormActions, + FormSection, + FormSectionLabel, + FormSectionContent, +} from 'components/ui/Forms' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' + +const EditWrapper = () => { + const formId = 'edit-wrapper-form' + const router = useRouter() + const { ui, vault } = useStore() + const { ref, id } = useParams() + const { project } = useProjectContext() + + const { data, isLoading } = useFDWsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const wrappers = data?.result ?? [] + const wrapper = wrappers.find((w) => Number(w.id) === Number(id)) + const wrapperMeta = WRAPPERS.find((w) => w.handlerName === wrapper?.handler) + + const { mutateAsync: createFDW } = useFDWCreateMutation() + const { mutateAsync: deleteFDW } = useFDWDeleteMutation() + + const [wrapperTables, setWrapperTables] = useState([]) + const [isEditingTable, setIsEditingTable] = useState(false) + const [selectedTableToEdit, setSelectedTableToEdit] = useState() + const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) + + const canCreateWrapper = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') + + const initialValues = + wrapperMeta !== undefined + ? { + wrapper_name: wrapper?.name, + server_name: wrapper?.server_name, + ...convertKVStringArrayToJson(wrapper?.server_options ?? []), + } + : {} + + useEffect(() => { + if (wrapper?.id) { + setWrapperTables(formatWrapperTables(wrapper?.tables ?? [])) + } + }, [wrapper?.id]) + + if (isLoading) { + return + } + + if (wrapper === undefined || wrapperMeta === undefined) { + return
Oh no
+ } + + const onUpdateTable = (values: any) => { + setWrapperTables((prev) => { + // if the new values have tableIndex, we are editing an existing table + if (values.tableIndex !== undefined) { + const tableIndex = values.tableIndex + const wrapperTables = [...prev] + delete values.tableIndex + wrapperTables[tableIndex] = values + return wrapperTables + } + return [...prev, values] + }) + setIsEditingTable(false) + setSelectedTableToEdit(undefined) + } + + const onSubmit = async (values: any, { setSubmitting }: any) => { + const validate = makeValidateRequired(wrapperMeta.server.options) + const errors: any = validate(values) + + const { wrapper_name } = values + if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper' + if (wrapperTables.length === 0) errors.tables = 'Please add at least one table' + if (!isEmpty(errors)) return setFormErrors(errors) + + setSubmitting(true) + try { + await deleteFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper, + wrapperMeta, + }) + + await createFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper: wrapperMeta, + formState: { ...values, server_name: `${wrapper_name}_server` }, + tables: wrapperTables, + }) + + ui.setNotification({ + category: 'success', + message: `Successfully updated ${wrapperMeta.label} foreign data wrapper`, + }) + setWrapperTables([]) + router.push(`/project/${ref}/database/wrappers`) + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Failed to create ${wrapperMeta.label} foreign data wrapper: ${error.message}`, + }) + } finally { + setSubmitting(false) + } + } + + return ( + <> +
+
+ +

Edit wrapper: {wrapper.name}

+ +
+ +
+ {({ isSubmitting, handleReset, values, initialValues, resetForm }: any) => { + const [loadingSecrets, setLoadingSecrets] = useState(false) + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted) + + useEffect(() => { + const fetchEncryptedValues = async () => { + setLoadingSecrets(true) + const res = await Promise.all( + encryptedOptions.map(async (option) => { + const [secret] = vault.listSecrets( + (secret: VaultSecret) => secret.name === `${wrapper.name}_${option.name}` + ) + if (secret !== undefined) { + const value = await vault.fetchSecretValue(secret.id) + return { [option.name]: value } + } else { + return { [option.name]: '' } + } + }) + ) + const secretValues = res.reduce((a: any, b: any) => { + const [key] = Object.keys(b) + return { ...a, [key]: b[key] } + }, {}) + + resetForm({ + values: { ...values, ...secretValues }, + initialValues: { ...initialValues, ...secretValues }, + }) + setLoadingSecrets(false) + } + + if (encryptedOptions.length > 0) fetchEncryptedValues() + }, []) + + return ( + + +
+ } + > + Wrapper Configuration}> + + + Your wrapper's server name will be updated to{' '} + {values.wrapper_name}_server + + ) : ( + <> + Your wrapper's server name is{' '} + {values.wrapper_name}_server + + ) + } + /> + + + {wrapperMeta.label} Configuration} + > + + {wrapperMeta.server.options.map((option) => ( + + ))} + + + +

Foreign Tables

+

+ You can query your data from these foreign tables after the wrapper is + created +

+ + } + > + + {wrapperTables.length === 0 ? ( +
+ +
+ ) : ( +
+ {wrapperTables.map((table, i) => ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ {wrapperMeta.tables[table.index].label}: {table.columns.join(', ')} +

+
+
+
+
+ ))} +
+ )} + {wrapperTables.length > 0 && ( +
+ +
+ )} + {wrapperTables.length === 0 && formErrors.tables && ( +

{formErrors.tables}

+ )} +
+
+ + ) + }} + + + + { + setSelectedTableToEdit(undefined) + setIsEditingTable(false) + }} + onSave={onUpdateTable} + initialData={selectedTableToEdit} + /> + + ) +} + +export default observer(EditWrapper) diff --git a/studio/components/interfaces/Database/Wrappers/InputField.tsx b/studio/components/interfaces/Database/Wrappers/InputField.tsx new file mode 100644 index 00000000000..a3839cc8fad --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/InputField.tsx @@ -0,0 +1,94 @@ +import Link from 'next/link' +import { FC, useState } from 'react' +import { Button, Input, IconHelpCircle, IconEye, IconEyeOff, IconLoader } from 'ui' +import { ServerOption } from './Wrappers.types' + +interface Props { + option: ServerOption + loading: boolean + error: any +} + +const InputField: FC = ({ option, loading, error }) => { + const [showHidden, setShowHidden] = useState(!option.hidden) + if (option.isTextArea) { + return ( +
+ +

{option.label}

+ {option.urlHelper !== undefined && ( + + + + + + )} +
+ } + value={loading ? 'Fetching value from Vault...' : undefined} + defaultValue={option.defaultValue ?? ''} + required={option.required ?? false} + /> + + ) + } else { + return ( + +

{option.label}

+ {option.urlHelper !== undefined && ( + + + + + + )} + + } + defaultValue={option.defaultValue ?? ''} + error={error} + value={loading ? 'Fetching value from Vault...' : undefined} + type={!option.hidden || loading ? 'text' : showHidden ? 'text' : 'password'} + disabled={loading} + actions={ + loading ? ( +
+
+ ) : option.hidden ? ( +
+
+ ) : null + } + /> + ) + } +} + +export default InputField diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 2df0ce425a4..0e336cdf86d 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -7,8 +7,6 @@ import { Button, IconExternalLink, IconLoader, - IconDatabase, - IconServer, IconTrash, IconEdit, } from 'ui' @@ -35,8 +33,6 @@ const WrapperRow: FC = ({ wrapperMeta, wrappers = [], isOpen, isLoading, const { mutateAsync: deleteFDW } = useFDWDeleteMutation() const [isSubmitting, setIsSubmitting] = useState(false) - console.log({ wrappers }) - const onDeleteWrapper = (wrapper: any) => { confirmAlert({ title: `Confirm to disable ${wrapper.name}`, @@ -146,7 +142,7 @@ const WrapperRow: FC = ({ wrapperMeta, wrappers = [], isOpen, isLoading,
-