Merge pull request #11537 from supabase/chore/update-fdw-ui

chore/update fdw UI
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-01-11 12:55:52 +08:00
commit d08312124f
25 files changed
+1516 -732

No files matched your search

+105 -105
View File
@@ -9706,75 +9706,6 @@
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
}
},
"node_modules/@tanstack/match-sorter-utils": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.1.1.tgz",
"integrity": "sha512-IdmEekEYxQsoLOR0XQyw3jD1GujBpRRYaGJYQUw1eOT1eUugWxdc7jomh1VQ1EKHcdwDLpLaCz/8y4KraU4T9A==",
"dependencies": {
"remove-accents": "0.4.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kentcdodds"
}
},
"node_modules/@tanstack/query-core": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.15.1.tgz",
"integrity": "sha512-+UfqJsNbPIVo0a9ANW0ZxtjiMfGLaaoIaL9vZeVycvmBuWywJGtSi7fgPVMCPdZQFOzMsaXaOsDtSKQD5xLRVQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.16.1.tgz",
"integrity": "sha512-PDE9u49wSDykPazlCoLFevUpceLjQ0Mm8i6038HgtTEKb/aoVnUZdlUP7C392ds3Cd75+EGlHU7qpEX06R7d9Q==",
"dependencies": {
"@tanstack/query-core": "4.15.1",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-native": "*"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.16.1.tgz",
"integrity": "sha512-VrDYLmG+OOcvGSZL5avG4R8jhqeMFP7pzW2sh2BWEV9UfI+aocG+CW8y8ygacxuKy48m8Tyo/xfe8H1z9BGb+g==",
"dependencies": {
"@tanstack/match-sorter-utils": "8.1.1",
"superjson": "^1.10.0",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "4.16.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@testdeck/core": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@testdeck/core/-/core-0.2.2.tgz",
@@ -37592,8 +37523,8 @@
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.29",
"@supabase/supabase-js": "^2.1.1",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"@tanstack/react-query": "^4.22.0",
"@tanstack/react-query-devtools": "^4.22.0",
"@zip.js/zip.js": "^2.6.61",
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
@@ -37727,6 +37658,75 @@
"tailwindcss": ">=3.0.0 || insiders"
}
},
"studio/node_modules/@tanstack/match-sorter-utils": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.7.6.tgz",
"integrity": "sha512-2AMpRiA6QivHOUiBpQAVxjiHAA68Ei23ZUMNaRJrN6omWiSFLoYrxGcT6BXtuzp0Jw4h6HZCmGGIM/gbwebO2A==",
"dependencies": {
"remove-accents": "0.4.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kentcdodds"
}
},
"studio/node_modules/@tanstack/query-core": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.22.0.tgz",
"integrity": "sha512-OeLyBKBQoT265f5G9biReijeP8mBxNFwY7ZUu1dKL+YzqpG5q5z7J/N1eT8aWyKuhyDTiUHuKm5l+oIVzbtrjw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"studio/node_modules/@tanstack/react-query": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.22.0.tgz",
"integrity": "sha512-P9o+HjG42uB/xHR6dMsJaPhtZydSe4v0xdG5G/cEj1oHZAXelMlm67/rYJNQGKgBamKElKogj+HYGF+NY2yHYg==",
"dependencies": {
"@tanstack/query-core": "4.22.0",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-native": "*"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"studio/node_modules/@tanstack/react-query-devtools": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.22.0.tgz",
"integrity": "sha512-YeYFBnfqvb+ZlA0IiJqiHNNSzepNhI1p2o9i8NlhQli9+Zrn230M47OBaBUs8qr3DD1dC2zGB1Dis50Ktz8gAA==",
"dependencies": {
"@tanstack/match-sorter-utils": "^8.7.0",
"superjson": "^1.10.0",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "4.22.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"studio/node_modules/@types/d3-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.2.tgz",
@@ -44538,38 +44538,6 @@
"mini-svg-data-uri": "^1.2.3"
}
},
"@tanstack/match-sorter-utils": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.1.1.tgz",
"integrity": "sha512-IdmEekEYxQsoLOR0XQyw3jD1GujBpRRYaGJYQUw1eOT1eUugWxdc7jomh1VQ1EKHcdwDLpLaCz/8y4KraU4T9A==",
"requires": {
"remove-accents": "0.4.2"
}
},
"@tanstack/query-core": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.15.1.tgz",
"integrity": "sha512-+UfqJsNbPIVo0a9ANW0ZxtjiMfGLaaoIaL9vZeVycvmBuWywJGtSi7fgPVMCPdZQFOzMsaXaOsDtSKQD5xLRVQ=="
},
"@tanstack/react-query": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.16.1.tgz",
"integrity": "sha512-PDE9u49wSDykPazlCoLFevUpceLjQ0Mm8i6038HgtTEKb/aoVnUZdlUP7C392ds3Cd75+EGlHU7qpEX06R7d9Q==",
"requires": {
"@tanstack/query-core": "4.15.1",
"use-sync-external-store": "^1.2.0"
}
},
"@tanstack/react-query-devtools": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.16.1.tgz",
"integrity": "sha512-VrDYLmG+OOcvGSZL5avG4R8jhqeMFP7pzW2sh2BWEV9UfI+aocG+CW8y8ygacxuKy48m8Tyo/xfe8H1z9BGb+g==",
"requires": {
"@tanstack/match-sorter-utils": "8.1.1",
"superjson": "^1.10.0",
"use-sync-external-store": "^1.2.0"
}
},
"@testdeck/core": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@testdeck/core/-/core-0.2.2.tgz",
@@ -63380,8 +63348,8 @@
"@supabase/shared-types": "^0.1.29",
"@supabase/supabase-js": "^2.1.1",
"@tailwindcss/typography": "^0.5.2",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"@tanstack/react-query": "^4.22.0",
"@tanstack/react-query-devtools": "^4.22.0",
"@testing-library/dom": "^8.19.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
@@ -63500,6 +63468,38 @@
"postcss-selector-parser": "6.0.10"
}
},
"@tanstack/match-sorter-utils": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.7.6.tgz",
"integrity": "sha512-2AMpRiA6QivHOUiBpQAVxjiHAA68Ei23ZUMNaRJrN6omWiSFLoYrxGcT6BXtuzp0Jw4h6HZCmGGIM/gbwebO2A==",
"requires": {
"remove-accents": "0.4.2"
}
},
"@tanstack/query-core": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.22.0.tgz",
"integrity": "sha512-OeLyBKBQoT265f5G9biReijeP8mBxNFwY7ZUu1dKL+YzqpG5q5z7J/N1eT8aWyKuhyDTiUHuKm5l+oIVzbtrjw=="
},
"@tanstack/react-query": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.22.0.tgz",
"integrity": "sha512-P9o+HjG42uB/xHR6dMsJaPhtZydSe4v0xdG5G/cEj1oHZAXelMlm67/rYJNQGKgBamKElKogj+HYGF+NY2yHYg==",
"requires": {
"@tanstack/query-core": "4.22.0",
"use-sync-external-store": "^1.2.0"
}
},
"@tanstack/react-query-devtools": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.22.0.tgz",
"integrity": "sha512-YeYFBnfqvb+ZlA0IiJqiHNNSzepNhI1p2o9i8NlhQli9+Zrn230M47OBaBUs8qr3DD1dC2zGB1Dis50Ktz8gAA==",
"requires": {
"@tanstack/match-sorter-utils": "^8.7.0",
"superjson": "^1.10.0",
"use-sync-external-store": "^1.2.0"
}
},
"@types/d3-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.2.tgz",
@@ -0,0 +1,294 @@
import Link from 'next/link'
import { isEmpty } from 'lodash'
import { 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 { checkPermissions, useParams, useStore } from 'hooks'
import {
FormPanel,
FormActions,
FormSection,
FormsContainer,
FormSectionLabel,
FormSectionContent,
} 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 CreateWrapper = () => {
const formId = 'create-wrapper-form'
const router = useRouter()
const { ui } = useStore()
const { ref, type } = useParams()
const { project } = useProjectContext()
const { mutateAsync: createFDW } = useFDWCreateMutation()
const [newTables, setNewTables] = 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 wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === type)
const initialValues =
wrapperMeta !== undefined
? {
wrapper_name: '',
server_name: '',
...Object.fromEntries(
wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
),
}
: {}
if (wrapperMeta === undefined) {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="space-y-2 flex flex-col items-center w-[400px]">
<p>Unsupported wrapper type</p>
<p className="text-sm text-center text-scale-1000">
The wrapper type {type} not supported by the dashboard. Head back to create a different
wrapper.
</p>
</div>
<Link href={`/project/${ref}/database/wrappers`}>
<a>
<Button type="default">Head back</Button>
</a>
</Link>
</div>
)
}
const onUpdateTable = (values: any) => {
setNewTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const newTables = [...prev]
delete values.tableIndex
newTables[tableIndex] = values
return newTables
}
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 (newTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) return setFormErrors(errors)
setSubmitting(true)
try {
await createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: { ...values, server_name: `${wrapper_name}_server` },
tables: newTables,
})
ui.setNotification({
category: 'success',
message: `Successfully created ${wrapperMeta.label} foreign data wrapper`,
})
setNewTables([])
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 (
<FormsContainer>
<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">Create a {wrapperMeta?.label} Wrapper</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 }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
return (
<FormPanel
disabled={!canCreateWrapper}
footer={
<div className="flex px-8 py-4">
<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 ?? '').length > 0 ? (
<>
Your wrapper's server name will be{' '}
<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={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}>
{newTables.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">
{newTables.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={() => {
setNewTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
))}
</div>
)}
{newTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{newTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
</div>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</FormsContainer>
)
}
export default observer(CreateWrapper)
@@ -0,0 +1,392 @@
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,
IconLoader,
} from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { VaultSecret } from 'types'
import { checkPermissions, useImmutableValue, useParams, useStore } from 'hooks'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-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,
FormsContainer,
} 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 foundWrapper = wrappers.find((w) => Number(w.id) === Number(id))
// this call to useImmutableValue should be removed if the redirect after update is also removed
const wrapper = useImmutableValue(foundWrapper)
const wrapperMeta = WRAPPERS.find((w) => w.handlerName === wrapper?.handler)
const { mutateAsync: updateFDW, isLoading: isSaving } = useFDWUpdateMutation()
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 (
<div className="flex items-center justify-center w-full h-full">
<Loading />
</div>
)
}
if (wrapper === undefined || wrapperMeta === undefined) {
if (isSaving) {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="flex items-center space-x-4">
<IconLoader className="animate-spin" size={16} />
<p className="text-sm">Updating wrapper</p>
</div>
</div>
)
} else {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="space-y-2 flex flex-col items-center w-[400px]">
<p>Unknown wrapper</p>
<p className="text-sm text-center text-scale-1000">
The wrapper ID {id} cannot be found in your project. Head back to select another
wrapper.
</p>
</div>
<Link href={`/project/${ref}/database/wrappers`}>
<a>
<Button type="default">Head back</Button>
</a>
</Link>
</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 updateFDW({
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 (
<FormsContainer>
<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 initialTables = formatWrapperTables(wrapper?.tables ?? [])
const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables)
const hasChanges = hasFormChanges || hasTableChanges
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 px-8 py-4">
<FormActions
form={formId}
isSubmitting={isSubmitting}
hasChanges={hasChanges}
handleReset={() => {
handleReset()
setWrapperTables(initialTables)
}}
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-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
</div>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</FormsContainer>
)
}
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
@@ -1,222 +1,50 @@
import Link from 'next/link'
import Image from 'next/image'
import { isEmpty } from 'lodash'
import { FC, useState } from 'react'
import {
Collapsible,
Input,
IconCheck,
IconChevronUp,
Button,
IconEdit,
IconTrash,
IconEye,
IconEyeOff,
IconCheckCircle,
IconExternalLink,
IconLoader,
IconHelpCircle,
} from 'ui'
import { FC } from 'react'
import { Collapsible, IconChevronUp, Button, IconExternalLink, IconTrash, IconEdit } from 'ui'
import { partition } from 'lodash'
import { useParams, useStore } from 'hooks'
import { ServerOption, Wrapper } from './Wrappers.types'
import { makeValidateRequired } from './Wrappers.utils'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { WrapperMeta } from './Wrappers.types'
import { FDW } from 'data/fdw/fdws-query'
import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import WrapperTableEditor from './WrapperTableEditor'
import InformationBox from 'components/ui/InformationBox'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
interface Props {
wrapper: Wrapper
tables: any[]
isLoading: boolean
isEnabled: boolean
wrappers: FDW[]
wrapperMeta: WrapperMeta
isOpen: boolean
onOpen: (wrapper: string) => void
}
const InputField: FC<{
option: ServerOption
value: any
error: any
onChange: (e: any) => void
}> = ({ option, value, error, onChange }) => {
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}
value={value}
onChange={onChange}
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 ?? ''}
required={option.required ?? false}
value={value}
onChange={onChange}
error={error}
className="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 WrapperRow: FC<Props> = ({ wrapper, tables, isLoading, isEnabled, isOpen, onOpen }) => {
const getInitialFormState = () =>
Object.fromEntries(
wrapper.server.options.map((option) => [option.name, option.defaultValue ?? ''])
)
const WrapperRow: FC<Props> = ({ wrappers = [], wrapperMeta, isOpen, onOpen }) => {
const { ui } = useStore()
const { ref } = useParams()
const { project } = useProjectContext()
const { mutateAsync: createFDW } = useFDWCreateMutation()
const { mutateAsync: deleteFDW } = useFDWDeleteMutation()
const [isSubmitting, setIsSubmitting] = useState(false)
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [newTables, setNewTables] = useState<any[]>([])
const [formState, setFormState] = useState(getInitialFormState)
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const noChanges = JSON.stringify(getInitialFormState()) === JSON.stringify(formState)
const onUpdateTable = (values: any) => {
setNewTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const newTables = [...prev]
delete values.tableIndex
newTables[tableIndex] = values
return newTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSaveWrapper = async () => {
const validate = makeValidateRequired(wrapper.server.options)
const errors: any = validate(formState)
if (newTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) {
setFormErrors(errors)
return
}
setIsSubmitting(true)
try {
await createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
formState,
newTables,
})
ui.setNotification({
category: 'success',
message: `Successfully created ${wrapper.label} foreign data wrapper`,
})
setNewTables([])
setFormState(getInitialFormState)
} catch (error: any) {
ui.setNotification({
error,
category: 'error',
message: `Failed to create ${wrapper.label} foreign data wrapper: ${error.message}`,
})
} finally {
setIsSubmitting(false)
}
}
const onDeleteWrapper = () => {
const onDeleteWrapper = (wrapper: any) => {
confirmAlert({
title: `Confirm to disable ${wrapper.label} wrapper`,
message: `Are you sure you want to disable the ${wrapper.label} wrapper? This will also remove all tables created with this wrapper.`,
title: `Confirm to disable ${wrapper.name}`,
message: `Are you sure you want to disable the ${wrapper.name} wrapper? This will also remove all tables created with this wrapper.`,
onAsyncConfirm: async () => {
try {
await deleteFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
wrapperMeta,
})
ui.setNotification({
category: 'success',
message: `Successfully removed ${wrapper.label} foreign data wrapper`,
message: `Successfully disabled ${wrapper.name} foreign data wrapper`,
})
} catch (error: any) {
ui.setNotification({
error,
category: 'error',
message: `Disabling ${wrapper.name} failed: ${error.message}`,
message: `Failed to disable ${wrapper.name}: ${error.message}`,
})
}
},
@@ -227,7 +55,7 @@ const WrapperRow: FC<Props> = ({ wrapper, tables, isLoading, isEnabled, isOpen,
<>
<Collapsible
open={isOpen}
onOpenChange={() => onOpen(wrapper.name)}
onOpenChange={() => onOpen(wrapperMeta.name)}
className={[
'bg-scale-100 dark:bg-scale-300 ',
'hover:bg-scale-200 dark:hover:bg-scale-500',
@@ -235,11 +63,11 @@ const WrapperRow: FC<Props> = ({ wrapper, tables, isLoading, isEnabled, isOpen,
'border-scale-300',
'dark:border-scale-500 hover:border-scale-500',
'dark:hover:border-scale-700 data-open:border-scale-700',
'data-open:pb-px col-span-12 mx-auto',
'col-span-12 mx-auto',
'-space-y-px overflow-hidden',
'transition border shadow hover:z-50',
'first:rounded-tl first:rounded-tr',
'last:rounded-bl last:rounded-br',
'first:rounded-tl first:rounded-tr first:!border-t',
'last:rounded-bl last:rounded-br last:border-t-0',
].join(' ')}
>
<Collapsible.Trigger asChild>
@@ -254,209 +82,102 @@ const WrapperRow: FC<Props> = ({ wrapper, tables, isLoading, isEnabled, isOpen,
width={14}
/>
<Image
src={wrapper.icon}
src={wrapperMeta.icon}
width={20}
height={20}
alt={`${wrapper.name} wrapper icon`}
alt={`${wrapperMeta.name} wrapper icon`}
/>
<span className="text-sm capitalize">{wrapper.label}</span>
<span className="text-sm capitalize">{wrapperMeta.label}</span>
</div>
<div className="flex items-center gap-3">
{isLoading ? (
<div className="flex items-center gap-1 px-1 py-1 text-xs rounded-full">
<IconLoader className="animate-spin" size={18} />
</div>
) : isEnabled ? (
<div className="flex items-center gap-1 px-1 py-1 text-xs border rounded-full border-brand-700 bg-brand-200 text-brand-900">
<span className="rounded-full bg-brand-900 p-0.5 text-xs text-brand-200">
<IconCheck strokeWidth={2} size={12} />
</span>
<span className="px-1">Enabled</span>
</div>
) : (
<div className="px-3 py-1 text-xs border rounded-md border-scale-500 bg-scale-100 text-scale-900 dark:border-scale-700 dark:bg-scale-300">
Disabled
</div>
)}
<div className="px-3 py-1 text-xs border rounded-md border-scale-500 bg-scale-100 text-scale-1200 dark:border-scale-700 dark:bg-scale-300">
{wrappers.length} wrapper{wrappers.length > 1 ? 's' : ''}
</div>
</div>
</button>
</Collapsible.Trigger>
{!isLoading && (
<>
{isEnabled ? (
<Collapsible.Content>
<div className="px-6 py-6 border-t group border-scale-500 bg-scale-100 text-scale-1200 dark:bg-scale-300">
<div className="max-w-lg mx-auto my-6 space-y-6">
<InformationBox
hideCollapse
defaultVisibility
icon={
<IconCheckCircle strokeWidth={1.5} size={18} className="text-brand-900" />
}
title={`${wrapper.label} foreign data wrapper is currently enabled`}
description="If you'd like to edit this wrapper, you'll need to disable the wrapper first and create it again with any updated configuration."
/>
<div className="space-y-2">
<p className="text-sm">Foreign Tables</p>
<div className="space-y-1">
{tables.map((table) => (
<div
key={table.id}
className="rounded border border-scale-600 bg-scale-100 py-3 dark:border-scale-500 dark:bg-scale-400 px-4 py-2 flex items-center justify-between space-x-4"
>
<div className="space-y-1">
<p className="text-sm">
{table.schema}.{table.name}
</p>
<p className="text-sm text-scale-1100">
Columns: {table.columns.map((c: any) => c.name).join(', ')}
</p>
</div>
<Link href={`/project/${ref}/editor/${table.id}`}>
<a>
<Button type="default">View table</Button>
</a>
</Link>
</div>
<Collapsible.Content>
<div className="border-t group border-scale-500 bg-scale-100 text-scale-1200 dark:bg-scale-300 divide-y">
{wrappers.map((wrapper) => {
const serverOptions = Object.fromEntries(
wrapper.server_options.map((option: any) => option.split('='))
)
const [encryptedMetadata, visibleMetadata] = partition(
wrapperMeta.server.options,
'hidden'
)
return (
<div key={wrapper.id} className="px-6 py-4 flex items-center justify-between">
<div className="space-y-1 w-3/4">
<div className="flex items-center space-x-2">
<p className="text-base">{wrapper.name}</p>
</div>
{visibleMetadata.map((metadata) => (
<div
key={metadata.name}
className="flex items-center space-x-2 text-sm text-scale-1000"
>
<p>{metadata.label}:</p>
<p>{serverOptions[metadata.name]}</p>
</div>
))}
{encryptedMetadata.map((metadata) => (
<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?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>
<IconExternalLink size={14} strokeWidth={1.5} />
</a>
</Link>
</div>
))}
<div className="!mt-3 space-y-1">
<p className="text-sm text-scale-1100">
Foreign tables: ({wrapper.tables.length})
</p>
<div className="flex flex-wrap gap-2">
{wrapper.tables.map((table: any) => (
<Link href={`/project/${ref}/editor/${table.id}`}>
<a>
<div
key={table.id}
className="text-sm border rounded px-2 py-1 transition bg-scale-400 hover:bg-scale-500"
>
{table.name}
</div>
</a>
</Link>
))}
</div>
</div>
<div className="flex items-center justify-end !mt-8">
<div className="flex items-center space-x-3">
<Link href={wrapper.docsUrl}>
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
</div>
<div className="flex items-center space-x-2">
<Link href={`/project/${ref}/database/wrappers/${wrapper.id}`}>
<a>
<Button
type="default"
loading={isSubmitting}
onClick={() => onDeleteWrapper()}
>
Disable wrapper
</Button>
</div>
</div>
icon={<IconEdit strokeWidth={1.5} />}
className="py-2"
/>
</a>
</Link>
<Button
type="default"
icon={<IconTrash strokeWidth={1.5} />}
className="py-2"
onClick={() => onDeleteWrapper(wrapper)}
/>
</div>
</div>
</Collapsible.Content>
) : (
<Collapsible.Content>
<div className="px-6 py-6 border-t group border-scale-500 bg-scale-100 text-scale-1200 dark:bg-scale-300">
<div className="max-w-lg mx-auto my-6 space-y-6">
{wrapper.server.options.map((option) => (
<InputField
option={option}
value={formState[option.name]}
error={formErrors[option.name]}
onChange={(e: any) => {
setFormState((prev) => ({ ...prev, [option.name]: e.target.value }))
setFormErrors((prev) => ({ ...prev, [option.name]: '' }))
}}
/>
))}
<div className="space-y-2">
<div className="space-y-1">
<div className="flex items-center justify-between">
<p className="text-sm text-scale-1100">Foreign Tables</p>
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add table
</Button>
</div>
</div>
<div className="space-y-2">
{newTables.length === 0 && (
<div className="flex items-center justify-center px-4 py-4 border rounded-md border-scale-600">
<p className="text-sm text-scale-1000">
Add foreign tables to query from after the wrapper is enabled
</p>
</div>
)}
{newTables.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">
{wrapper.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={() => {
setNewTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
))}
{newTables.length === 0 && formErrors.tables && (
<p className="text-sm text-red-900">{formErrors.tables}</p>
)}
</div>
<div className="flex items-center justify-between !mt-8">
<Link href={wrapper.docsUrl}>
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
<div className="flex items-center space-x-3">
<Button
type="default"
htmlType="reset"
onClick={() => {
onOpen('')
setNewTables([])
setFormState(getInitialFormState)
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
loading={isSubmitting}
disabled={noChanges}
onClick={() => onSaveWrapper()}
>
Save
</Button>
</div>
</div>
</div>
</div>
</div>
</Collapsible.Content>
)}
</>
)}
)
})}
</div>
</Collapsible.Content>
</Collapsible>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapper.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</>
)
}
@@ -1,6 +1,6 @@
import { Wrapper } from './Wrappers.types'
import { WrapperMeta } from './Wrappers.types'
export const WRAPPERS: Wrapper[] = [
export const WRAPPERS: WrapperMeta[] = [
{
name: 'stripe_wrapper',
handlerName: 'stripe_fdw_handler',
@@ -10,7 +10,6 @@ export const WRAPPERS: Wrapper[] = [
label: 'Stripe',
docsUrl: 'https://supabase.com/docs/guides/database/wrappers/stripe',
server: {
name: 'stripe_server',
options: [
{
name: 'api_key_id',
@@ -415,7 +414,6 @@ export const WRAPPERS: Wrapper[] = [
label: 'Firebase',
docsUrl: 'https://supabase.com/docs/guides/database/wrappers/firebase',
server: {
name: 'firebase_server',
options: [
{
name: 'project_id',
@@ -2,34 +2,32 @@ import Link from 'next/link'
import { useState } from 'react'
import { Button, IconExternalLink } from 'ui'
import { observer } from 'mobx-react-lite'
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useParams, useStore } from 'hooks'
import { useStore } from 'hooks'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { WRAPPERS } from './Wrappers.constants'
import WrapperRow from './WrapperRow'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import WrappersDropdown from './WrappersDropdown'
import WrappersDisabledState from './WrappersDisabledState'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { groupBy } from 'lodash'
const Wrappers = () => {
const { ref } = useParams()
const { ui, meta } = useStore()
const { meta } = useStore()
const { project } = useProjectContext()
const { data, isLoading } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappers = data?.result ?? []
const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name))
const [open, setOpen] = useState<string>('')
const [isEnabling, setIsEnabling] = useState<boolean>(false)
const wrappers = data?.result ?? []
const groupedWrappers = groupBy(wrappers, 'handler')
const wrappersExtension = meta.extensions.byId('wrappers')
const vaultExtension = meta.extensions.byId('supabase_vault')
const isLoadingExtensions = meta.extensions.isLoading
const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined
const isWrappersEnabled =
wrappersExtension !== undefined &&
@@ -37,48 +35,6 @@ const Wrappers = () => {
vaultExtension !== undefined &&
vaultExtension?.installed_version !== null
const canToggleWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions')
const onEnableWrappers = async () => {
if (wrappersExtension === undefined || vaultExtension === undefined) return
setIsEnabling(true)
const requiredExtensions = await Promise.all([
await meta.extensions.create({
schema: wrappersExtension.schema ?? 'extensions',
name: wrappersExtension.name,
version: wrappersExtension.default_version,
cascade: true,
}),
await meta.extensions.create({
schema: vaultExtension.schema ?? 'vault',
name: vaultExtension.name,
version: vaultExtension.default_version,
cascade: true,
}),
])
const errors = requiredExtensions.filter(
(res) => res.error && !res.error.message.includes('already exists')
)
if (errors.length > 0) {
ui.setNotification({
error: errors,
category: 'error',
message: `Failed to enable Wrappers for your project: ${errors
.map((x) => x.message)
.join(', ')}`,
})
} else {
ui.setNotification({
category: 'success',
message: 'Wrappers is now enabled for your project!',
})
}
setIsEnabling(false)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -88,159 +44,63 @@ const Wrappers = () => {
Query your data warehouse directly from your database, or third-party APIs using SQL.
</div>
</div>
{/* [Joshen TODO] For when we support multiple instances per wrapper */}
{/* <div>
<Dropdown
side="bottom"
align="end"
size="small"
overlay={
<>
{WRAPPERS.map((wrapper, idx) => (
<>
<Dropdown.Item
key={wrapper.name}
icon={
<Image
src={wrapper.icon}
width={20}
height={20}
alt={`${wrapper.name} wrapper icon`}
/>
}
onClick={() => {}}
>
{wrapper.label}
</Dropdown.Item>
{idx !== WRAPPERS.length - 1 && <Dropdown.Separator />}
</>
))}
</>
}
>
<Button type="primary">Add wrapper</Button>
</Dropdown>
</div> */}
<div className="flex items-center space-x-2">
<Link href="">
<a target="_blank">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
Documentation
</Button>
</a>
</Link>
{isWrappersEnabled && <WrappersDropdown />}
</div>
</div>
{isLoadingExtensions ? (
<div className="border rounded border-scale-500 p-12 space-y-2">
{isLoadingExtensions || isLoading ? (
<div className="p-12 space-y-2 border rounded border-scale-500">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : isWrappersEnabled ? (
<div>
{WRAPPERS.map((wrapper, i) => {
return (
<WrapperRow
key={i}
wrapper={wrapper}
tables={wrappers.find((w) => w.name === wrapper.server.name)?.tables ?? []}
isLoading={isLoading}
isEnabled={enabledWrapperNamesSet.has(wrapper.server.name)}
isOpen={open === wrapper.name}
onOpen={(wrapperName) => {
if (open !== wrapperName) setOpen(wrapperName)
else setOpen('')
}}
/>
)
})}
{wrappers.length === 0 ? (
<div
className={[
'border rounded border-scale-500 px-20 py-16',
'flex flex-col items-center justify-center space-y-4',
].join(' ')}
>
<p className="text-sm">No wrappers created yet</p>
<WrappersDropdown align="center" buttonText="Create a new wrapper" />
</div>
) : (
<>
{/* [Joshen] This probably needs to change anyways so dont get too stuck with this */}
{WRAPPERS.map((wrapper, i) => {
const createdWrappers = groupedWrappers[wrapper.handlerName] ?? []
if (createdWrappers.length > 0) {
return (
<WrapperRow
key={i}
wrapperMeta={wrapper}
wrappers={createdWrappers}
isOpen={open === wrapper.name}
onOpen={(wrapperName) => {
if (open !== wrapperName) setOpen(wrapperName)
else setOpen('')
}}
/>
)
} else {
return null
}
})}
</>
)}
</div>
) : (
<div>
<div
className="w-full px-12 py-12 bg-white bg-no-repeat border rounded dark:bg-scale-200 border-scale-500"
style={{
backgroundSize: '45%',
backgroundPosition: '105% 40%',
backgroundImage: ui.isDarkTheme
? 'url("/img/wrappers-dark.png")'
: 'url("/img/wrappers-light.png")',
}}
>
<div className="w-3/5 space-y-8">
<div className="space-y-2">
<h4 className="text-lg">Supabase Wrappers</h4>
<p className="text-sm text-scale-1100">
Supabase Wrappers is a framework for building Postgres Foreign Data Wrappers (FDW)
which connect Postgres to external systems. Query your data warehouse or
third-party APIs directly from your database.
</p>
</div>
{isNotAvailable ? (
<div className="space-y-4">
<div className="rounded border border-scale-500 px-4 py-2 flex items-center justify-between">
<div>
<p className="text-scale-1100 text-sm">
Wrappers is not available for this project yet.
</p>
<p className="text-scale-1000 text-sm">
Do reach out to us if you're interested!
</p>
</div>
<div>
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20wrappers`}
>
<a target="_blank">
<Button type="primary">Contact us</Button>
</a>
</Link>
</div>
</div>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Link href="https://supabase.com/docs/guides/database/wrappers">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Wrappers
</Button>
</a>
</Link>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<Link href="https://supabase.com/docs/guides/database/wrappers">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Wrappers
</Button>
</a>
</Link>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="primary"
loading={isEnabling}
disabled={isNotAvailable || isEnabling || !canToggleWrappers}
onClick={() => onEnableWrappers()}
>
Enable Wrappers
</Button>
</Tooltip.Trigger>
{!canToggleWrappers && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
You need additional permissions to enable Wrappers for this project
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
)}
</div>
</div>
</div>
<WrappersDisabledState />
)}
</div>
)
@@ -1,4 +1,4 @@
export type Wrapper = {
export type WrapperMeta = {
name: string
handlerName: string
validatorName: string
@@ -22,7 +22,6 @@ export type ServerOption = {
}
export type Server = {
name: string
options: ServerOption[]
}
@@ -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('=')))
}
@@ -0,0 +1,156 @@
import Link from 'next/link'
import { useState } from 'react'
import { Button, IconExternalLink } from 'ui'
import { observer } from 'mobx-react-lite'
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useParams, useStore } from 'hooks'
const WrappersDisabledState = () => {
const { ui, meta } = useStore()
const { ref } = useParams()
const wrappersExtension = meta.extensions.byId('wrappers')
const vaultExtension = meta.extensions.byId('supabase_vault')
const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined
const [isEnabling, setIsEnabling] = useState<boolean>(false)
const canToggleWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions')
const onEnableWrappers = async () => {
if (wrappersExtension === undefined || vaultExtension === undefined) return
setIsEnabling(true)
const requiredExtensions = await Promise.all([
await meta.extensions.create({
schema: wrappersExtension.schema ?? 'extensions',
name: wrappersExtension.name,
version: wrappersExtension.default_version,
cascade: true,
}),
await meta.extensions.create({
schema: vaultExtension.schema ?? 'vault',
name: vaultExtension.name,
version: vaultExtension.default_version,
cascade: true,
}),
])
const errors = requiredExtensions.filter(
(res) => res.error && !res.error.message.includes('already exists')
)
if (errors.length > 0) {
ui.setNotification({
error: errors,
category: 'error',
message: `Failed to enable Wrappers for your project: ${errors
.map((x) => x.message)
.join(', ')}`,
})
} else {
ui.setNotification({
category: 'success',
message: 'Wrappers is now enabled for your project!',
})
}
setIsEnabling(false)
}
return (
<div>
<div
className="w-full px-12 py-12 bg-white bg-no-repeat border rounded dark:bg-scale-200 border-scale-500"
style={{
backgroundSize: '45%',
backgroundPosition: '105% 40%',
backgroundImage: ui.isDarkTheme
? 'url("/img/wrappers-dark.png")'
: 'url("/img/wrappers-light.png")',
}}
>
<div className="w-3/5 space-y-8">
<div className="space-y-2">
<h4 className="text-lg">Supabase Wrappers</h4>
<p className="text-sm text-scale-1100">
Supabase Wrappers is a framework for building Postgres Foreign Data Wrappers (FDW)
which connect Postgres to external systems. Query your data warehouse or third-party
APIs directly from your database.
</p>
</div>
{isNotAvailable ? (
<div className="space-y-4">
<div className="rounded border border-scale-500 px-4 py-2 flex items-center justify-between">
<div>
<p className="text-scale-1100 text-sm">
Wrappers is not available for this project yet.
</p>
<p className="text-scale-1000 text-sm">
Do reach out to us if you're interested!
</p>
</div>
<div>
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20wrappers`}
>
<a target="_blank">
<Button type="primary">Contact us</Button>
</a>
</Link>
</div>
</div>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Link href="https://supabase.com/docs/guides/database/wrappers">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Wrappers
</Button>
</a>
</Link>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<Link href="https://supabase.com/docs/guides/database/wrappers">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
About Wrappers
</Button>
</a>
</Link>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="primary"
loading={isEnabling}
disabled={isNotAvailable || isEnabling || !canToggleWrappers}
onClick={() => onEnableWrappers()}
>
Enable Wrappers
</Button>
</Tooltip.Trigger>
{!canToggleWrappers && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
You need additional permissions to enable Wrappers for this project
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
)}
</div>
</div>
</div>
)
}
export default observer(WrappersDisabledState)
@@ -0,0 +1,59 @@
import Link from 'next/link'
import Image from 'next/image'
import { FC, Fragment } from 'react'
import { observer } from 'mobx-react-lite'
import { Button, Dropdown, IconPlus } from 'ui'
import { useParams } from 'hooks'
import { WRAPPERS } from './Wrappers.constants'
interface Props {
buttonText?: string
align?: 'center' | 'end'
}
const WrapperDropdown: FC<Props> = ({ buttonText = 'Add wrapper', align = 'end' }) => {
const { ref } = useParams()
return (
<Dropdown
side="bottom"
align={align}
size="small"
overlay={
<>
{WRAPPERS.map((wrapper, idx) => (
<Fragment key={idx}>
<Link
href={`/project/${ref}/database/wrappers/new?type=${wrapper.name.toLowerCase()}`}
>
<a>
<Dropdown.Item
key={wrapper.name}
icon={
<Image
src={wrapper.icon}
width={20}
height={20}
alt={`${wrapper.name} wrapper icon`}
/>
}
>
{wrapper.label}
</Dropdown.Item>
</a>
</Link>
{idx !== WRAPPERS.length - 1 && <Dropdown.Separator />}
</Fragment>
))}
</>
}
>
<Button type="primary" icon={<IconPlus strokeWidth={1.5} />}>
{buttonText}
</Button>
</Dropdown>
)
}
export default observer(WrapperDropdown)
@@ -7,6 +7,8 @@ import RolesSettings from './Roles/RolesSettings'
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'
@@ -28,6 +30,8 @@ export {
RolesSettings,
Extensions,
Wrappers,
CreateWrapper,
EditWrapper,
PublicationsList,
PublicationsTables,
BackupsList,
@@ -1,25 +1,31 @@
import Link from 'next/link'
import { observer } from 'mobx-react-lite'
import { FC, Fragment, useState } from 'react'
import { IconSearch, Input, Button, Listbox, IconLoader, IconExternalLink } from 'ui'
import { FC, Fragment, useState, useEffect } from 'react'
import { IconSearch, Input, Button, Listbox, IconLoader, IconExternalLink, IconX } from 'ui'
import { useStore, useParams } from 'hooks'
import SecretRow from './SecretRow'
import EditSecretModal from './EditSecretModal'
import DeleteSecretModal from './DeleteSecretModal'
import AddNewSecretModal from './AddNewSecretModal'
import Divider from 'components/ui/Divider'
import { useStore } from 'hooks'
interface Props {}
const SecretsManagement: FC<Props> = ({}) => {
const { vault } = useStore()
const { search } = useParams()
const [searchValue, setSearchValue] = useState<string>('')
const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at')
const [showAddSecretModal, setShowAddSecretModal] = useState(false)
const [selectedSecretToEdit, setSelectedSecretToEdit] = useState<any>()
const [selectedSecretToRemove, setSelectedSecretToRemove] = useState<any>()
useEffect(() => {
if (search !== undefined) setSearchValue(search)
}, [search])
const secrets = (
searchValue.length > 0
? vault.listSecrets(
@@ -48,6 +54,19 @@ const SecretsManagement: FC<Props> = ({}) => {
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
icon={<IconSearch strokeWidth={2} size={16} />}
actions={
searchValue.length > 0
? [
<Button
size="tiny"
type="text"
icon={<IconX />}
className="px-1"
onClick={() => setSearchValue('')}
/>,
]
: []
}
/>
<div className="w-32">
<Listbox size="small" value={selectedSort} onChange={setSelectedSort}>
@@ -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])
+28 -28
View File
@@ -1,40 +1,44 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { AvailableColumn, Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import {
AvailableColumn,
WrapperMeta,
} from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { executeSql } from 'data/sql/execute-sql-query'
import { sqlKeys } from 'data/sql/keys'
import { wrapWithTransaction } from 'data/sql/utils/transaction'
import { useStore } from 'hooks'
export type FDWCreateVariables = {
projectRef?: string
connectionString?: string
wrapper: Wrapper
wrapperMeta: WrapperMeta
formState: {
[k: string]: string
}
newTables: any[]
tables: any[]
}
export function getFDWCreateSql({
wrapper,
export function getCreateFDWSql({
wrapperMeta,
formState,
newTables,
}: Pick<FDWCreateVariables, 'wrapper' | 'formState' | 'newTables'>) {
const newSchemasSql = newTables
tables,
}: Pick<FDWCreateVariables, 'wrapperMeta' | 'formState' | 'tables'>) {
const newSchemasSql = tables
.filter((table) => table.is_new_schema)
.map((table) => /* SQL */ `create schema if not exists ${table.schema_name};`)
.join('\n')
const createWrapperSql = /* SQL */ `
create foreign data wrapper ${wrapper.name}
handler ${wrapper.handlerName}
validator ${wrapper.validatorName};
create foreign data wrapper ${formState.wrapper_name}
handler ${wrapperMeta.handlerName}
validator ${wrapperMeta.validatorName};
`
const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
const unencryptedOptions = wrapper.server.options.filter((option) => !option.encrypted)
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
const unencryptedOptions = wrapperMeta.server.options.filter((option) => !option.encrypted)
const createEncryptedKeysSqlArray = encryptedOptions.map((option) => {
const key = `${wrapper.name}_${option.name}`
const key = `${formState.wrapper_name}_${option.name}`
// Escape single quotes in postgresql by doubling them up
const value = (formState[option.name] || '').replace(/'/g, "''")
@@ -67,13 +71,13 @@ export function getFDWCreateSql({
${encryptedOptions
.map(
(option) =>
/* SQL */ `select id into v_${option.name} from pgsodium.valid_key where name = '${wrapper.name}_${option.name}' limit 1;`
/* SQL */ `select id into v_${option.name} from pgsodium.valid_key where name = '${formState.wrapper_name}_${option.name}' limit 1;`
)
.join('\n')}
execute format(
E'create server ${wrapper.server.name}\\n'
' foreign data wrapper ${wrapper.name}\\n'
E'create server ${formState.server_name}\\n'
' foreign data wrapper ${formState.wrapper_name}\\n'
' options (\\n'
' ${optionsSqlArray}\\n'
' );',
@@ -82,9 +86,9 @@ export function getFDWCreateSql({
end $$;
`
const createTablesSql = newTables
const createTablesSql = tables
.map((newTable) => {
const table = wrapper.tables[newTable.index]
const table = wrapperMeta.tables[newTable.index]
const columns: AvailableColumn[] = newTable.columns
.map((name: string) => table.availableColumns.find((c) => c.name === name))
@@ -94,7 +98,7 @@ export function getFDWCreateSql({
create foreign table ${newTable.schema_name}.${newTable.table_name} (
${columns.map((column) => `${column.name} ${column.type}`).join(',\n ')}
)
server ${wrapper.server.name}
server ${formState.server_name}
options (
${Object.entries(newTable)
.filter(
@@ -112,8 +116,6 @@ export function getFDWCreateSql({
.join('\n\n')
const sql = /* SQL */ `
begin;
${newSchemasSql}
${createWrapperSql}
@@ -123,8 +125,6 @@ export function getFDWCreateSql({
${createServerSql}
${createTablesSql}
commit;
`
return sql
@@ -133,11 +133,11 @@ export function getFDWCreateSql({
export async function createFDW({
projectRef,
connectionString,
wrapper,
wrapperMeta,
formState,
newTables,
tables,
}: FDWCreateVariables) {
const sql = getFDWCreateSql({ wrapper, formState, newTables })
const sql = wrapWithTransaction(getCreateFDWSql({ wrapperMeta, formState, tables }))
const { result } = await executeSql({ projectRef, connectionString, sql })
@@ -158,7 +158,7 @@ export const useFDWCreateMutation = ({
const { projectRef } = variables
await Promise.all([
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws']), { refetchType: 'all' }),
vault.load(),
])
+17 -10
View File
@@ -1,17 +1,23 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { executeSql } from 'data/sql/execute-sql-query'
import { sqlKeys } from 'data/sql/keys'
import { wrapWithTransaction } from 'data/sql/utils/transaction'
import { useStore } from 'hooks'
import { FDW } from './fdws-query'
export type FDWDeleteVariables = {
projectRef?: string
connectionString?: string
wrapper: Wrapper
wrapper: FDW
wrapperMeta: WrapperMeta
}
export const getDeleteFDWSql = ({ wrapper }: Pick<FDWDeleteVariables, 'wrapper'>) => {
const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
export const getDeleteFDWSql = ({
wrapper,
wrapperMeta,
}: Pick<FDWDeleteVariables, 'wrapper' | 'wrapperMeta'>) => {
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
const deleteEncryptedSecretsSqlArray = encryptedOptions.map((option) => {
const key = `${wrapper.name}_${option.name}`
@@ -26,24 +32,25 @@ export const getDeleteFDWSql = ({ wrapper }: Pick<FDWDeleteVariables, 'wrapper'>
const deleteEncryptedSecretsSql = deleteEncryptedSecretsSqlArray.join('\n')
const sql = /* SQL */ `
begin;
drop foreign data wrapper if exists ${wrapper.name} cascade;
${deleteEncryptedSecretsSql}
commit;
`
return sql
}
export async function deleteFDW({ projectRef, connectionString, wrapper }: FDWDeleteVariables) {
export async function deleteFDW({
projectRef,
connectionString,
wrapper,
wrapperMeta,
}: FDWDeleteVariables) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const sql = getDeleteFDWSql({ wrapper })
const sql = wrapWithTransaction(getDeleteFDWSql({ wrapper, wrapperMeta }))
const { result } = await executeSql({ projectRef, connectionString, sql })
+81
View File
@@ -0,0 +1,81 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { executeSql } from 'data/sql/execute-sql-query'
import { sqlKeys } from 'data/sql/keys'
import { wrapWithTransaction } from 'data/sql/utils/transaction'
import { useStore } from 'hooks'
import { getCreateFDWSql } from './fdw-create-mutation'
import { getDeleteFDWSql } from './fdw-delete-mutation'
import { FDW } from './fdws-query'
export type FDWUpdateVariables = {
projectRef?: string
connectionString?: string
wrapper: FDW
wrapperMeta: WrapperMeta
formState: {
[k: string]: string
}
tables: any[]
}
export const getUpdateFDWSql = ({
wrapper,
wrapperMeta,
formState,
tables,
}: Pick<FDWUpdateVariables, 'wrapper' | 'wrapperMeta' | 'formState' | 'tables'>) => {
const deleteWrapperSql = getDeleteFDWSql({ wrapper, wrapperMeta })
const createWrapperSql = getCreateFDWSql({ wrapperMeta, formState, tables })
const sql = /* SQL */ `
${deleteWrapperSql}
${createWrapperSql}
`
return sql
}
export async function updateFDW({
projectRef,
connectionString,
wrapper,
wrapperMeta,
formState,
tables,
}: FDWUpdateVariables) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const sql = wrapWithTransaction(getUpdateFDWSql({ wrapper, wrapperMeta, formState, tables }))
const { result } = await executeSql({ projectRef, connectionString, sql })
return result
}
type FDWUpdateData = Awaited<ReturnType<typeof updateFDW>>
export const useFDWUpdateMutation = ({
onSuccess,
...options
}: Omit<UseMutationOptions<FDWUpdateData, unknown, FDWUpdateVariables>, 'mutationFn'> = {}) => {
const queryClient = useQueryClient()
const { vault } = useStore()
return useMutation<FDWUpdateData, unknown, FDWUpdateVariables>((vars) => updateFDW(vars), {
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
vault.load(),
])
await onSuccess?.(data, variables, context)
},
...options,
})
}
+17 -5
View File
@@ -5,11 +5,14 @@ export const getFDWsSql = () => {
const sql = /* SQL */ `
select
s.oid as "id",
s.srvname as "name",
w.fdwname as "name",
s.srvname as "server_name",
s.srvoptions as "server_options",
c.proname as "handler",
(
select jsonb_agg(
jsonb_build_object(
'id', c.oid,
'id', c.oid::bigint,
'schema', relnamespace::regnamespace::text,
'name', c.relname,
'columns', (
@@ -21,13 +24,17 @@ 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;
from pg_catalog.pg_foreign_server s
join pg_catalog.pg_foreign_data_wrapper w on s.srvfdw = w.oid
join pg_catalog.pg_proc c on w.fdwhandler = c.oid;
`
return sql
@@ -41,12 +48,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[]
}
+9
View File
@@ -0,0 +1,9 @@
export function wrapWithTransaction(sql: string) {
return /* SQL */ `
begin;
${sql}
commit;
`
}
+1
View File
@@ -1,6 +1,7 @@
export * from './misc/useStore'
export * from './misc/withAuth'
export * from './misc/useOptimisticSqlSnippetCreate'
export * from './misc/useImmutableValue'
export * from './misc/useNotifications'
export * from './misc/usePlatformStatus'
export * from './misc/useParams'
+14
View File
@@ -0,0 +1,14 @@
import { useRef } from 'react'
/**
* Once a non-undefined value is passed, it will never change.
*/
export function useImmutableValue<T>(value: T | undefined): T | undefined {
const ref = useRef<T | undefined>(value)
if (!ref.current && value) {
ref.current = value
}
return ref.current
}
+2 -2
View File
@@ -30,8 +30,8 @@
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.29",
"@supabase/supabase-js": "^2.1.1",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"@tanstack/react-query": "^4.22.0",
"@tanstack/react-query-devtools": "^4.22.0",
"@zip.js/zip.js": "^2.6.61",
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
@@ -0,0 +1,21 @@
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 { 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">{page}</DatabaseLayout>
export default observer(DatabaseWrappersNew)
@@ -0,0 +1,21 @@
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 { CreateWrapper } 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 <CreateWrapper />
}
DatabaseWrappersNew.getLayout = (page) => <DatabaseLayout title="Wrappers">{page}</DatabaseLayout>
export default observer(DatabaseWrappersNew)