mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
Merge pull request #11537 from supabase/chore/update-fdw-ui
chore/update fdw UI
This commit is contained in:
25 files changed
+1516
-732
No files matched your search
Generated
+105
-105
@@ -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])
|
||||
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function wrapWithTransaction(sql: string) {
|
||||
return /* SQL */ `
|
||||
begin;
|
||||
|
||||
${sql}
|
||||
|
||||
commit;
|
||||
`
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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)
|
||||
File renamed without changes.
@@ -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)
|
||||
Reference in new issue
Block a user