mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
Implement FDW update flow
This commit is contained in:
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])
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user