Implement FDW update flow

This commit is contained in:
Joshen Lim committed 2023-01-09 16:52:39 +08:00
1 parent 27a0728ef9
commit 1efffc9ee8
11 files changed
+530 -117

No files matched your search

@@ -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 (
<div className="text-area-text-sm text-area-resize-none">
<Input.TextArea
key={option.name}
id={option.name}
name={option.name}
label={
<div className="flex items-center space-x-2">
<p>{option.label}</p>
{option.urlHelper !== undefined && (
<Link href={option.urlHelper}>
<a target="_blank">
<IconHelpCircle
strokeWidth={2}
size={14}
className="text-scale-1000 hover:text-scale-1200 cursor-pointer transition"
/>
</a>
</Link>
)}
</div>
}
defaultValue={option.defaultValue ?? ''}
required={option.required ?? false}
error={error}
className="input-mono"
rows={6}
/>
</div>
)
} else {
return (
<Input
key={option.name}
id={option.name}
name={option.name}
label={
<div className="flex items-center space-x-2">
<p>{option.label}</p>
{option.urlHelper !== undefined && (
<Link href={option.urlHelper}>
<a target="_blank">
<IconHelpCircle
strokeWidth={2}
size={14}
className="text-scale-1000 hover:text-scale-1200 cursor-pointer transition"
/>
</a>
</Link>
)}
</div>
}
defaultValue={option.defaultValue ?? ''}
error={error}
className={`${option.name === 'server_name' ? '' : 'input-mono'}`}
type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'}
actions={
option.hidden ? (
<div className="flex items-center justify-center mr-1">
<Button
type="default"
icon={showHidden ? <IconEye /> : <IconEyeOff />}
onClick={() => setShowHidden(!showHidden)}
/>
</div>
) : 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 = () => {
<InputField
key={option.name}
option={option}
loading={false}
error={formErrors[option.name]}
/>
))}
@@ -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<any[]>([])
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 <Loading />
}
if (wrapper === undefined || wrapperMeta === undefined) {
return <div>Oh no</div>
}
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 (
<>
<div>
<div className="relative flex items-center justify-between mb-6">
<div
className={[
'transition cursor-pointer',
'absolute -left-20 top-1 opacity-75 hover:opacity-100',
].join(' ')}
>
<Link href={`/project/${ref}/database/wrappers`}>
<a>
<div className="flex items-center space-x-2">
<IconArrowLeft strokeWidth={1.5} size={14} />
<p className="text-sm">Back</p>
</div>
</a>
</Link>
</div>
<h3 className="mb-2 text-xl text-scale-1200">Edit wrapper: {wrapper.name}</h3>
<div className="flex items-center space-x-2">
<Link href="https://supabase.github.io/wrappers/stripe/">
<a target="_blank">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
Documentation
</Button>
</a>
</Link>
</div>
</div>
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit}>
{({ 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 (
<FormPanel
disabled={!canCreateWrapper}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isSubmitting}
hasChanges={hasChanges}
handleReset={handleReset}
helper={
!canCreateWrapper
? 'You need additional permissions to create a foreign data wrapper'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
values.wrapper_name !== initialValues.wrapper_name ? (
<>
Your wrapper's server name will be updated to{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
<>
Your wrapper's server name is{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
)
}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options.map((option) => (
<InputField
key={option.name}
option={option}
loading={option.encrypted ? loadingSecrets : false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-scale-1000 mt-2 w-[90%]">
You can query your data from these foreign tables after the wrapper is
created
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperTables.length === 0 ? (
<div className="flex justify-end translate-y-4">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
) : (
<div className="space-y-2">
{wrapperTables.map((table, i) => (
<div className="flex items-center justify-between px-4 py-2 border rounded-md border-scale-600">
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-scale-1000">
{wrapperMeta.tables[table.index].label}: {table.columns.join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<IconEdit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<IconTrash />}
onClick={() => {
setWrapperTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
))}
</div>
)}
{wrapperTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{wrapperTables.length === 0 && formErrors.tables && (
<p className="text-sm text-red-900 text-right">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
</div>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</>
)
}
export default observer(EditWrapper)
@@ -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<Props> = ({ option, loading, error }) => {
const [showHidden, setShowHidden] = useState(!option.hidden)
if (option.isTextArea) {
return (
<div className="text-area-text-sm text-area-resize-none">
<Input.TextArea
key={option.name}
rows={6}
error={error}
className="input-mono"
disabled={loading}
id={option.name}
name={option.name}
label={
<div className="flex items-center space-x-2">
<p>{option.label}</p>
{option.urlHelper !== undefined && (
<Link href={option.urlHelper}>
<a target="_blank">
<IconHelpCircle
strokeWidth={2}
size={14}
className="text-scale-1000 hover:text-scale-1200 cursor-pointer transition"
/>
</a>
</Link>
)}
</div>
}
value={loading ? 'Fetching value from Vault...' : undefined}
defaultValue={option.defaultValue ?? ''}
required={option.required ?? false}
/>
</div>
)
} else {
return (
<Input
key={option.name}
id={option.name}
name={option.name}
label={
<div className="flex items-center space-x-2">
<p>{option.label}</p>
{option.urlHelper !== undefined && (
<Link href={option.urlHelper}>
<a target="_blank">
<IconHelpCircle
strokeWidth={2}
size={14}
className="text-scale-1000 hover:text-scale-1200 cursor-pointer transition"
/>
</a>
</Link>
)}
</div>
}
defaultValue={option.defaultValue ?? ''}
error={error}
value={loading ? 'Fetching value from Vault...' : undefined}
type={!option.hidden || loading ? 'text' : showHidden ? 'text' : 'password'}
disabled={loading}
actions={
loading ? (
<div className="flex items-center justify-center mr-1">
<Button disabled type="default" icon={<IconLoader className="animate-spin" />} />
</div>
) : option.hidden ? (
<div className="flex items-center justify-center mr-1">
<Button
type="default"
icon={showHidden ? <IconEye /> : <IconEyeOff />}
onClick={() => setShowHidden(!showHidden)}
/>
</div>
) : null
}
/>
)
}
}
export default InputField
@@ -7,8 +7,6 @@ import {
Button,
IconExternalLink,
IconLoader,
IconDatabase,
IconServer,
IconTrash,
IconEdit,
} from 'ui'
@@ -35,8 +33,6 @@ const WrapperRow: FC<Props> = ({ 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<Props> = ({ wrapperMeta, wrappers = [], isOpen, isLoading,
<div key={metadata.name} className="flex items-center space-x-2 text-sm">
<p className="text-scale-1000">{metadata.label}:</p>
<Link
href={`/project/${ref}/settings/vault/secrets?name=${wrapper.name}_${metadata.name}`}
href={`/project/${ref}/settings/vault/secrets?search=${wrapper.name}_${metadata.name}`}
>
<a className="transition text-scale-1000 hover:text-scale-1100 flex items-center space-x-2">
<span>Encrypted in Vault</span>
@@ -176,13 +172,16 @@ const WrapperRow: FC<Props> = ({ wrapperMeta, wrappers = [], isOpen, isLoading,
</div>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
loading={isSubmitting}
icon={<IconEdit strokeWidth={1.5} />}
className="py-2"
onClick={() => onDeleteWrapper(wrapper)}
/>
<Link href={`/project/${ref}/database/wrappers/${wrapper.id}`}>
<a>
<Button
type="default"
loading={isSubmitting}
icon={<IconEdit strokeWidth={1.5} />}
className="py-2"
/>
</a>
</Link>
<Button
type="default"
loading={isSubmitting}
@@ -19,3 +19,24 @@ export const makeValidateRequired =
return errors
}
export const formatWrapperTables = (tables: any[]) => {
return tables.map((table, index: number) => {
const object = table.options.find((option: string) => option.startsWith('object='))
const objectValue = object !== undefined ? object.split('=')[1] : undefined
return {
index,
columns: table.columns.map((column: any) => column.name),
is_new_schema: false,
object: objectValue,
schema: table.schema,
schema_name: table.schema,
table_name: table.name,
}
})
}
export const convertKVStringArrayToJson = (values: string[]) => {
return Object.fromEntries(values.map((value) => value.split('=')))
}
@@ -8,6 +8,7 @@ import Extensions from './Extensions/Extensions'
import Wrappers from './Wrappers/Wrappers'
import CreateWrapper from './Wrappers/CreateWrapper'
import EditWrapper from './Wrappers/EditWrapper'
import PublicationsList from './Publications/PublicationsList'
import PublicationsTables from './Publications/PublicationsTables'
@@ -30,6 +31,7 @@ export {
Extensions,
Wrappers,
CreateWrapper,
EditWrapper,
PublicationsList,
PublicationsTables,
BackupsList,
@@ -14,7 +14,7 @@ interface Props {}
const SecretsManagement: FC<Props> = ({}) => {
const { vault } = useStore()
const { name } = useParams()
const { search } = useParams()
const [searchValue, setSearchValue] = useState<string>('')
const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at')
@@ -23,8 +23,8 @@ const SecretsManagement: FC<Props> = ({}) => {
const [selectedSecretToRemove, setSelectedSecretToRemove] = useState<any>()
useEffect(() => {
if (name !== undefined) setSearchValue(name)
}, [name])
if (search !== undefined) setSearchValue(search)
}, [search])
const secrets = (
searchValue.length > 0
@@ -14,7 +14,7 @@ interface Props {
}
const DatabaseLayout: FC<Props> = ({ title, children }) => {
const { meta, ui, backups } = useStore()
const { meta, ui, vault, backups } = useStore()
const { isInitialized, isLoading, error } = meta.tables
const project = ui.selectedProject
@@ -35,6 +35,7 @@ const DatabaseLayout: FC<Props> = ({ title, children }) => {
meta.publications.load()
backups.load()
vault.load()
}
}, [ui.selectedProject?.ref])
+7 -7
View File
@@ -11,15 +11,15 @@ export type FDWCreateVariables = {
formState: {
[k: string]: string
}
newTables: any[]
tables: any[]
}
export function getFDWCreateSql({
wrapper,
formState,
newTables,
}: Pick<FDWCreateVariables, 'wrapper' | 'formState' | 'newTables'>) {
const newSchemasSql = newTables
tables,
}: Pick<FDWCreateVariables, 'wrapper' | 'formState' | 'tables'>) {
const newSchemasSql = tables
.filter((table) => table.is_new_schema)
.map((table) => /* SQL */ `create schema if not exists ${table.schema_name};`)
.join('\n')
@@ -82,7 +82,7 @@ export function getFDWCreateSql({
end $$;
`
const createTablesSql = newTables
const createTablesSql = tables
.map((newTable) => {
const table = wrapper.tables[newTable.index]
@@ -135,9 +135,9 @@ export async function createFDW({
connectionString,
wrapper,
formState,
newTables,
tables,
}: FDWCreateVariables) {
const sql = getFDWCreateSql({ wrapper, formState, newTables })
const sql = getFDWCreateSql({ wrapper, formState, tables })
const { result } = await executeSql({ projectRef, connectionString, sql })
+9 -2
View File
@@ -26,10 +26,12 @@ export const getFDWsSql = () => {
)
from pg_catalog.pg_attribute a
where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
)
),
'options', t.ftoptions
)
)
from pg_catalog.pg_class c
join pg_catalog.pg_foreign_table t on c.oid = t.ftrelid
where c.oid = any (select t.ftrelid from pg_catalog.pg_foreign_table t where t.ftserver = s.oid)
) as "tables"
from pg_catalog.pg_foreign_server s
@@ -48,12 +50,17 @@ export type FDWColumn = {
export type FDWTable = {
id: string
name: string
schema: string
columns: FDWColumn[]
options: string[]
}
export type FDW = {
id: string
id: number
name: string
handler: string
server_name: string
server_options: string[]
tables: FDWTable[]
}
@@ -0,0 +1,26 @@
import { observer } from 'mobx-react-lite'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
import { checkPermissions } from 'hooks'
import { DatabaseLayout } from 'components/layouts'
import NoPermission from 'components/ui/NoPermission'
import { FormsContainer } from 'components/ui/Forms'
import { EditWrapper } from 'components/interfaces/Database'
const DatabaseWrappersNew: NextPageWithLayout = () => {
const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
if (!canReadWrappers) {
return <NoPermission isFullPage resourceText="view foreign data wrappers" />
}
return <EditWrapper />
}
DatabaseWrappersNew.getLayout = (page) => (
<DatabaseLayout title="Wrappers">
<FormsContainer>{page}</FormsContainer>
</DatabaseLayout>
)
export default observer(DatabaseWrappersNew)