chore: migrate foreign wrapper forms to react-hook-form (#44512)

## Problem

Foreign wrapper forms still use `formik` but we now use
`react-hook-form` everywhere and we'd like to reduce our dependencies.

## Solution

- [x] Write e2e tests for wrappers
- [x] Migrate to `react-hook-form`

## Notes

I tried to cover the 3 cases I identified for foreign wrappers with e2e
tests:
- Add all available tables to a new schema (stripe)
- Add selected tables to a new table (stripe)
- Create dynamic columns (s3 wrapper)

However, they are not exhaustive as I can't test the integration
actually works, only that it was created successfully. Besides, I can't
test the Iceberg wrapper case as it needs actual S3 buckets.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced column-type selector with searchable combobox, enum support,
icons, and optional recommendation prompts.

* **Refactor**
* Migrated many wrapper/table/editor forms to a schema-driven form
system with stronger validation, dynamic field arrays, and consistent
form controls.
* Updated input field integration to work with the new form control
model.

* **Bug Fixes**
* Improved handling of missing wrapper/error states during wrapper
loading.

* **Tests**
* Added unit tests for form schemas and end-to-end tests for wrapper
creation flows.

* **Chores**
  * Removed legacy dynamic-columns component.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-04-09 09:20:27 +02:00
1 parent 96f1b2cd7b
commit 914677ed4b
11 files changed
+2091 -1276

No files matched your search

@@ -0,0 +1,298 @@
import {
POSTGRES_DATA_TYPE_OPTIONS,
POSTGRES_DATA_TYPES,
RECOMMENDED_ALTERNATIVE_DATA_TYPE,
} from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants'
import type { PostgresDataTypeOption } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types'
import type { EnumeratedType } from 'data/enumerated-types/enumerated-types-query'
import {
Calendar,
Check,
ChevronsUpDown,
ExternalLink,
Hash,
ListPlus,
ToggleRight,
Type,
} from 'lucide-react'
import Link from 'next/link'
import { ReactNode, useId, useState } from 'react'
import { Control } from 'react-hook-form'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
CriticalIcon,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
Input_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
ScrollArea,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
interface ColumnTypeProps {
name: string
className?: string
enumTypes: EnumeratedType[]
description?: ReactNode
showRecommendation?: boolean
control: Control
}
export const ColumnType = ({
className,
name,
enumTypes = [],
showRecommendation = false,
control,
}: ColumnTypeProps) => {
const [open, setOpen] = useState(false)
const listboxId = useId()
const availableTypes = POSTGRES_DATA_TYPES.concat(
enumTypes.map((type) => type.format.replaceAll('"', ''))
)
const unsupportedDataTypeText = `This column's data type cannot be changed via the Table Editor as it is not supported yet. You can do so through the SQL Editor instead.`
const getOptionByName = (name: string) => {
// handle built in types
const pgOption = POSTGRES_DATA_TYPE_OPTIONS.find((option) => option.name === name)
if (pgOption) return pgOption
// handle custom enums
const enumType = enumTypes.find((type) => type.format === name)
return enumType ? { ...enumType, type: 'enum' } : undefined
}
const inferIcon = (type: string) => {
switch (type) {
case 'number':
return <Hash size={14} className="text-foreground" strokeWidth={1.5} />
case 'time':
return <Calendar size={14} className="text-foreground" strokeWidth={1.5} />
case 'text':
return <Type size={14} className="text-foreground" strokeWidth={1.5} />
case 'json':
return (
<div className="text-foreground" style={{ padding: '0px 1px' }}>
{'{ }'}
</div>
)
case 'jsonb':
return (
<div className="text-foreground" style={{ padding: '0px 1px' }}>
{'{ }'}
</div>
)
case 'bool':
return <ToggleRight size={14} className="text-foreground" strokeWidth={1.5} />
default:
return <ListPlus size={16} className="text-foreground" strokeWidth={1.5} />
}
}
return (
<FormField_Shadcn_
control={control}
name={name}
render={({ field }) => {
const isAvailableType = field.value ? availableTypes.includes(field.value) : true
const recommendation = RECOMMENDED_ALTERNATIVE_DATA_TYPE[field.value]
if (!isAvailableType) {
return (
<FormItemLayout
layout="vertical"
name={name}
label="Column type"
description={unsupportedDataTypeText}
className={className}
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} id={name} disabled readOnly />
</FormControl_Shadcn_>
</FormItemLayout>
)
}
return (
<FormItem_Shadcn_ className={cn('flex flex-col space-y-2', className)}>
<FormLabel_Shadcn_ className="text-foreground flex gap-2 items-center break-words">
Type
</FormLabel_Shadcn_>
<Popover_Shadcn_ modal open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
role="combobox"
size={'small'}
aria-expanded={open}
aria-controls={listboxId}
className={cn(
'w-full justify-between bg-background-control',
!field.value && 'text-foreground-lighter'
)}
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
title={field.value && field.value.replaceAll('"', '')}
>
{field.value ? (
<div className="flex gap-2 items-center">
<span>{inferIcon(getOptionByName(field.value)?.type ?? '')}</span>
<span className="block truncate">{field.value.replaceAll('"', '')}</span>
</div>
) : (
'Choose a column type...'
)}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
id={listboxId}
className="w-[460px] p-0"
side="bottom"
align="center"
>
<Command_Shadcn_>
<CommandInput_Shadcn_
placeholder="Search types..."
// [Joshen] Addresses style issues when this component is being used in the old Form component
// Specifically in WrapperDynamicColumns - can be cleaned up once we're no longer using that
className="!bg-transparent focus:!shadow-none focus:!ring-0 text-xs"
/>
<CommandEmpty_Shadcn_>Type not found.</CommandEmpty_Shadcn_>
<CommandList_Shadcn_>
<ScrollArea className="h-[240px]">
<CommandGroup_Shadcn_ heading="Postgres data types">
{POSTGRES_DATA_TYPE_OPTIONS.map((option: PostgresDataTypeOption) => (
<CommandItem_Shadcn_
key={option.name}
value={option.name}
className={cn(
'relative',
option.name === field.value ? 'bg-surface-200' : ''
)}
onSelect={(value: string) => {
field.onChange(value)
setOpen(false)
}}
>
<div className="flex items-center gap-2 pr-6">
<span>{inferIcon(option.type)}</span>
<span className="text-foreground">{option.name}</span>
<span className="text-foreground-lighter">{option.description}</span>
</div>
<span className="absolute right-3 top-2">
{option.name === field.value ? (
<Check className="text-brand" size={14} />
) : (
''
)}
</span>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
{enumTypes.length > 0 && (
<>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_ heading="Other types">
{enumTypes.map((option) => (
<CommandItem_Shadcn_
key={option.id}
value={option.format}
className={cn(
'relative',
option.format === field.value ? 'bg-surface-200' : ''
)}
onSelect={(value: string) => {
// [Joshen] For camel case types specifically, format property includes escaped double quotes
// which will cause the POST columns call to error out. So we strip it specifically in this context
field.onChange(
option.schema === 'public' ? value.replaceAll('"', '') : value
)
setOpen(false)
}}
>
<div className="flex items-center gap-2">
<div>
<ListPlus
size={16}
className="text-foreground"
strokeWidth={1.5}
/>
</div>
<span className="text-foreground">
{option.format.replaceAll('"', '')}
</span>
{option.comment !== undefined && (
<span
title={option.comment ?? ''}
className="text-foreground-lighter"
>
{option.comment}
</span>
)}
{option.format === field.value && (
<span className="absolute right-3 top-2">
<Check className="text-brand" size={14} />
</span>
)}
</div>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</>
)}
</ScrollArea>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
{showRecommendation && recommendation !== undefined && (
<Alert_Shadcn_ variant="warning" className="mt-2">
<CriticalIcon />
<AlertTitle_Shadcn_>
{' '}
It is recommended to use{' '}
<code className="text-code-inline">{recommendation.alternative}</code> instead
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
Postgres recommends against using the data type{' '}
<code className="text-code-inline">{field.value}</code> unless you have a very
specific use case.
</p>
<div className="flex items-center space-x-2 mt-3">
<Button asChild type="default" icon={<ExternalLink />}>
<Link href={recommendation.reference} target="_blank" rel="noreferrer">
Read more
</Link>
</Button>
<Button
type="primary"
onClick={() => field.onChange(recommendation.alternative)}
>
Use {recommendation.alternative}
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</FormItem_Shadcn_>
)
}}
/>
)
}
@@ -1,26 +1,35 @@
import { isEmpty } from 'lodash'
import { useMemo, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useMemo, useRef, useState } from 'react'
import { SubmitHandler, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Form,
Input,
Card,
CardContent,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import * as z from 'zod'
import { CreateWrapperSheetProps } from './CreateWrapperSheet'
import InputField from './InputField'
import { makeValidateRequired } from './Wrappers.utils'
import {
FormSection,
FormSectionContent,
FormSectionLabel,
} from '@/components/ui/Forms/FormSection'
import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation'
@@ -30,7 +39,57 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const FORM_ID = 'create-wrapper-form'
const requiredFields: Record<Target, { name: string; required: boolean }[]> = {
const S3TableSchema = z.object({
target: z.literal('S3Tables'),
source_schema: z.string().min(1, 'Please provide a namespace name'),
wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
target_schema: z.string().min(1, 'Please provide an unique target schema'),
vault_aws_access_key_id: z.string().min(1, 'Required'),
vault_aws_secret_access_key: z.string().min(1, 'Required'),
region_name: z.string().min(1, 'Required'),
vault_aws_s3table_bucket_arn: z.string().min(1, 'Required'),
})
type S3TableSchemaType = z.infer<typeof S3TableSchema>
const R2CatalogSchema = z.object({
target: z.literal('R2Catalog'),
source_schema: z.string().min(1, 'Please provide a namespace name'),
wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
target_schema: z.string().min(1, 'Please provide an unique target schema'),
vault_aws_access_key_id: z.string().min(1, 'Required'),
vault_aws_secret_access_key: z.string().min(1, 'Required'),
vault_token: z.string().min(1, 'Required'),
warehouse: z.string().min(1, 'Required'),
s3: z.object({ endpoint: z.string().min(1, 'Required') }),
catalog_uri: z.string().min(1, 'Required'),
})
type R2CatalogSchemaType = z.infer<typeof R2CatalogSchema>
const IcebergRestCatalogSchema = z.object({
target: z.literal('IcebergRestCatalog'),
source_schema: z.string().min(1, 'Please provide a namespace name'),
wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
target_schema: z.string().min(1, 'Please provide an unique target schema'),
vault_aws_access_key_id: z.string().optional(),
vault_aws_secret_access_key: z.string().optional(),
region_name: z.string().optional(),
vault_aws_s3table_bucket_arn: z.string().optional(),
vault_token: z.string().optional(),
warehouse: z.string().optional(),
s3: z.object({ endpoint: z.string().min(1, 'Required') }),
catalog_uri: z.string().optional(),
})
type IcebergRestCatalogSchemaType = z.infer<typeof IcebergRestCatalogSchema>
const formSchema = z.discriminatedUnion('target', [
S3TableSchema,
R2CatalogSchema,
IcebergRestCatalogSchema,
])
type FormSchema = z.infer<typeof formSchema>
const targetFields: Record<Target, { name: string; required: boolean }[]> = {
S3Tables: [
{ name: 'vault_aws_access_key_id', required: true },
{ name: 'vault_aws_secret_access_key', required: true },
@@ -59,8 +118,19 @@ const requiredFields: Record<Target, { name: string; required: boolean }[]> = {
type Target = 'S3Tables' | 'R2Catalog' | 'IcebergRestCatalog'
const INITIAL_VALUES = {
wrapper_name: '',
source_schema: '',
target_schema: '',
target: 'S3Tables',
vault_aws_access_key_id: '',
vault_aws_s3table_bucket_arn: '',
vault_aws_secret_access_key: '',
region_name: '',
} satisfies FormSchema
export const CreateIcebergWrapperSheet = ({
wrapperMeta: wrapperMetaOriginal,
wrapperMeta,
onDirty,
onClose,
onCloseWithConfirmation,
@@ -69,10 +139,6 @@ export const CreateIcebergWrapperSheet = ({
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const [selectedTarget, setSelectedTarget] = useState<Target>('S3Tables')
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutateAsync: createFDW, isPending: isCreatingWrapper } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
@@ -80,65 +146,59 @@ export const CreateIcebergWrapperSheet = ({
},
})
const wrapperMeta = useMemo(() => {
const fields = requiredFields[selectedTarget]
return {
...wrapperMetaOriginal,
server: {
options: wrapperMetaOriginal.server.options
// when the target changes, change the wrapperMeta field
.filter((option) => fields.find((field) => field.name === option.name))
.map((option) => {
const field = fields.find((field) => field.name === option.name)
return {
...option,
required: field?.required ?? false,
}
}),
},
}
}, [wrapperMetaOriginal, selectedTarget])
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref!,
connectionString: project?.connectionString,
})
const initialValues = {
wrapper_name: '',
server_name: '',
source_schema: wrapperMeta.sourceSchemaOption?.defaultValue ?? '',
target_schema: '',
...Object.fromEntries(
wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
),
}
const { mutateAsync: createSchema } = useSchemaCreateMutation()
const { mutateAsync: createSchema, isPending: isCreatingSchema } = useSchemaCreateMutation()
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: INITIAL_VALUES,
})
const { resetField, formState, setError, watch } = form
const { isDirty, isSubmitting } = formState
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
useEffect(() => {
onDirty(isDirty)
}, [onDirty, isDirty])
if (values.source_schema.length === 0) {
errors.source_schema = 'Please provide a namespace name'
}
if (values.wrapper_name.length === 0) {
errors.wrapper_name = 'Please provide a name for your wrapper'
}
const currentTarget = useRef<FormSchema['target']>(INITIAL_VALUES.target)
useEffect(() => {
const subscription = watch((values) => {
if (!values.target || values.target === currentTarget.current) return
currentTarget.current = values.target
if (values.target_schema.length === 0) {
errors.target_schema = 'Please provide an unique target schema'
}
const fields = targetFields[values.target]
if (!fields) return
wrapperMeta.server.options.forEach((option) => {
// @ts-expect-error Can't reconcile with form schema
resetField(option.name, { defaultValue: option.defaultValue ?? '' })
})
})
return () => subscription.unsubscribe()
}, [resetField, watch, wrapperMeta])
const onSubmit: SubmitHandler<FormSchema> = async (values) => {
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
if (foundSchema) {
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
setError('target_schema', {
type: 'validate',
message: 'This schema already exists. Please specify a unique schema name.',
})
return
}
setFormErrors(errors)
if (!isEmpty(errors)) {
return
let formValues: Record<string, string> = {}
if (values.target === 'R2Catalog' || values.target === 'IcebergRestCatalog') {
const { s3, ...otherFormValues } = values
formValues = otherFormValues
formValues['s3.endpoint'] = s3.endpoint
} else {
formValues = values
}
try {
@@ -153,7 +213,7 @@ export const CreateIcebergWrapperSheet = ({
connectionString: project?.connectionString,
wrapperMeta,
formState: {
...values,
...formValues,
server_name: `${values.wrapper_name}_server`,
supabase_target_schema: values.target_schema,
},
@@ -179,186 +239,223 @@ export const CreateIcebergWrapperSheet = ({
}
}
const isLoading = isCreatingWrapper || isCreatingSchema
const isLoading = isCreatingWrapper || isSubmitting
const wrapperName = useWatch({ name: 'wrapper_name', control: form.control })
const target = useWatch({ name: 'target', control: form.control })
const targetOptions = wrapperMeta.server.options
.filter((option) => targetFields[target].find((field) => field.name === option.name))
.map((option) => {
return {
...option,
required: !!targetFields[target].find((field) => field.name === option.name)?.required,
}
})
return (
<>
<div className="h-full" tabIndex={-1}>
<Form
id={FORM_ID}
initialValues={initialValues}
onSubmit={onSubmit}
className="flex-grow flex flex-col h-full"
>
{({ values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
onDirty(hasChanges)
return (
<>
<SheetHeader>
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<div className="flex-grow overflow-y-auto">
<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-code-inline">{values.wrapper_name}_server</code>
</>
) : (
''
)
}
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col h-full"
>
<SheetHeader>
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<SheetSection className="flex-grow overflow-y-auto">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="wrapper_name"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Wrapper Name"
description={
wrapperName.length > 0 ? (
<>
Your wrapper's server name will be{' '}
<code className="text-code-inline">{wrapperName}_server</code>
</>
) : (
''
)
}
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<Separator />
<FormSection header={<FormSectionLabel>Data target</FormSectionLabel>}>
<FormSectionContent loading={false} className="text-sm">
<RadioGroupStacked
value={selectedTarget}
onValueChange={(value) => setSelectedTarget(value as Target)}
>
<RadioGroupStackedItem
key="S3Tables"
value="S3Tables"
label="AWS S3 Tables"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
AWS S3 storage that's optimized for analytics workloads.
</p>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Data target</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="target"
render={({ field }) => (
<FormItemLayout layout="vertical">
<div>
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
<RadioGroupStackedItem
key="S3Tables"
value="S3Tables"
label="AWS S3 Tables"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
AWS S3 storage that's optimized for analytics workloads.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="R2Catalog"
value="R2Catalog"
label="Cloudflare R2 Catalog"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Managed Apache Iceberg built directly into your R2 bucket.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="IcebergRestCatalog"
value="IcebergRestCatalog"
label="Iceberg REST Catalog"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Can be used with any S3-compatible storage.
</p>
</div>
</div>
</RadioGroupStackedItem>
</RadioGroupStacked>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="R2Catalog"
value="R2Catalog"
label="Cloudflare R2 Catalog"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Managed Apache Iceberg built directly into your R2 bucket.
</p>
</div>
</div>
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="IcebergRestCatalog"
value="IcebergRestCatalog"
label="Iceberg REST Catalog"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Can be used with any S3-compatible storage.
</p>
</div>
</div>
</RadioGroupStackedItem>
</RadioGroupStacked>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<Separator />
<FormSection
header={
<FormSectionLabel>
<p>Foreign Schema</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from the foreign tables in the specified schema
after the wrapper is created.
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperMeta.sourceSchemaOption && (
<div>
<InputField
key="source_schema"
option={wrapperMeta.sourceSchemaOption}
loading={false}
error={formErrors['source_schema']}
/>
<p className="text-foreground-lighter text-sm">
{wrapperMeta.sourceSchemaOption.description}
</p>
</div>
)}
<div className="flex flex-col gap-2">
<InputField
key="target_schema"
option={{
name: 'target_schema',
label: 'Specify a new schema to create all wrapper tables in',
required: true,
encrypted: false,
secureEntry: false,
}}
loading={false}
error={formErrors['target_schema']}
/>
<p className="text-foreground-lighter text-sm">
A new schema will be created. For security purposes, the wrapper tables
from the foreign schema cannot be created within an existing schema.
</p>
</div>
</FormSectionContent>
</FormSection>
</div>
</FormItemLayout>
)}
/>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onCloseWithConfirmation}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
loading={isLoading}
>
Create wrapper
</Button>
</SheetFooter>
</>
)
}}
</Form>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
{targetOptions.map((option) =>
option.hidden ? (
<input
key={`${option.name}-${option.required}-${option.hidden}`}
type="hidden"
// @ts-expect-error Can't reconcile with form schema
{...form.register(option.name)}
/>
) : (
<CardContent key={`${option.name}-${option.required}-${option.hidden}`}>
<InputField control={form.control} option={option} />
</CardContent>
)
)}
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Schema</PageSectionTitle>
<PageSectionDescription>
You can query your data from the foreign tables in the specified schema after
the wrapper is created.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
{wrapperMeta.sourceSchemaOption && (
<InputField
control={form.control}
option={wrapperMeta.sourceSchemaOption}
/>
)}
</CardContent>
<CardContent>
<InputField
control={form.control}
option={{
name: 'target_schema',
label: 'Specify a new schema to create all wrapper tables in',
description:
'A new schema will be created. For security purposes, the wrapper tables from the foreign schema cannot be created within an existing schema.',
required: true,
encrypted: false,
secureEntry: false,
}}
/>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
</SheetSection>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onCloseWithConfirmation}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
loading={isLoading}
disabled={isLoading || !isDirty}
>
Create wrapper
</Button>
</SheetFooter>
</form>
</Form_Shadcn_>
</div>
</>
)
@@ -1,30 +1,40 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { Edit, Trash } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Form,
Input,
Card,
CardContent,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
WarningIcon,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import * as z from 'zod'
import InputField from './InputField'
import { WrapperMeta } from './Wrappers.types'
import { makeValidateRequired } from './Wrappers.utils'
import { FormattedWrapperTable, getWrapperCreationFormSchema, NewTable } from './Wrappers.utils'
import WrapperTableEditor from './WrapperTableEditor'
import {
FormSection,
FormSectionContent,
FormSectionLabel,
} from '@/components/ui/Forms/FormSection'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation'
import { invalidateSchemasQuery, useSchemasQuery } from '@/data/database/schemas-query'
@@ -54,12 +64,9 @@ export const CreateWrapperSheet = ({
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const [newTables, setNewTables] = useState<any[]>([])
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [selectedMode, setSelectedMode] = useState<'tables' | 'schema'>(
wrapperMeta.tables.length > 0 ? 'tables' : 'schema'
)
const [selectedTableToEdit, setSelectedTableToEdit] = useState<
FormattedWrapperTable | undefined
>()
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
@@ -72,20 +79,6 @@ export const CreateWrapperSheet = ({
? wrappersExtension?.installed_version >= '0.5.0'
: false
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutateAsync: createFDW, isPending: isCreatingWrapper } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
setNewTables([])
const hasNewSchema = newTables.some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
onClose()
},
})
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref!,
connectionString: project?.connectionString,
@@ -94,66 +87,90 @@ export const CreateWrapperSheet = ({
const initialValues = {
wrapper_name: '',
server_name: '',
mode: wrapperMeta.tables.length > 0 ? 'tables' : 'schema',
source_schema: wrapperMeta.sourceSchemaOption?.defaultValue ?? '',
target_schema: '',
...Object.fromEntries(
wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
),
tables: [] as Array<FormattedWrapperTable>,
}
const formSchema = getWrapperCreationFormSchema(wrapperMeta)
type FormSchema = z.infer<typeof formSchema>
const form = useForm<FormSchema>({
defaultValues: initialValues,
resolver: zodResolver(formSchema),
})
const { getValues, setError } = form
const { errors, isDirty, isSubmitting } = form.formState
useEffect(() => {
onDirty(isDirty)
}, [onDirty, isDirty])
const {
fields: tablesField,
append: appendTable,
remove: removeTable,
insert: insertTable,
} = useFieldArray({
control: form.control,
name: 'tables',
})
const { mutateAsync: createSchema, isPending: isCreatingSchema } = useSchemaCreateMutation()
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)
const onUpdateTable = (values: FormattedWrapperTable) => {
if (values.index !== undefined) {
removeTable(values.index)
insertTable(values.index, values)
} else {
appendTable(values)
}
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
const { mutateAsync: createFDW, isPending: isCreatingWrapper } = useFDWCreateMutation({
onSuccess: (data) => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
if (values.wrapper_name.length === 0) {
errors.wrapper_name = 'Please provide a name for your wrapper'
}
const { tables } = getValues()
const hasNewSchema = (tables as Record<string, any>[]).some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
if (selectedMode === 'tables') {
if (newTables.length === 0) {
errors.tables = 'Please provide at least one table'
}
onClose()
form.reset()
},
})
const onSubmit: SubmitHandler<FormSchema> = async (values) => {
const { mode, tables = [], ...wrapperValues } = values
if (mode === 'tables' && tables.length === 0) {
setError('tables', {
type: 'validate',
message: 'Please provide at least one table.',
})
return
}
if (selectedMode === 'schema') {
if (wrapperMeta.sourceSchemaOption && values.source_schema.length === 0) {
errors.source_schema = 'Please provide a source schema'
}
if (values.target_schema.length === 0) {
errors.target_schema = 'Please provide an unique target schema'
}
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
if (mode === 'schema') {
const foundSchema = schemas?.find((s) => s.name === wrapperValues.target_schema)
if (foundSchema) {
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
setError('target_schema', {
type: 'validate',
message: 'This schema already exists. Please specify a unique schema name.',
})
return
}
}
setFormErrors(errors)
if (!isEmpty(errors)) return
try {
if (selectedMode === 'schema') {
if (mode === 'schema') {
await createSchema({
projectRef: project?.ref,
connectionString: project?.connectionString,
name: values.target_schema,
name: wrapperValues.target_schema,
})
}
@@ -162,19 +179,14 @@ export const CreateWrapperSheet = ({
connectionString: project?.connectionString,
wrapperMeta,
formState: {
...values,
server_name: `${values.wrapper_name}_server`,
supabase_target_schema: selectedMode === 'schema' ? values.target_schema : undefined,
...wrapperValues,
server_name: `${wrapperValues.wrapper_name}_server`,
supabase_target_schema: mode === 'schema' ? wrapperValues.target_schema : undefined,
},
mode:
selectedMode === 'schema'
? wrapperMeta.sourceSchemaOption
? 'schema'
: 'skip'
: 'tables',
tables: newTables,
sourceSchema: values.source_schema,
targetSchema: values.target_schema,
mode: mode === 'schema' ? (wrapperMeta.sourceSchemaOption ? 'schema' : 'skip') : 'tables',
tables,
sourceSchema: wrapperValues.source_schema,
targetSchema: wrapperValues.target_schema,
})
sendEvent({
@@ -194,287 +206,295 @@ export const CreateWrapperSheet = ({
}
const isLoading = isCreatingWrapper || isCreatingSchema
const wrapper_name = useWatch({ name: 'wrapper_name', control: form.control })
const mode = useWatch({ name: 'mode', control: form.control })
return (
<>
<div className="h-full" tabIndex={-1}>
<Form
id={FORM_ID}
initialValues={initialValues}
onSubmit={onSubmit}
className="flex-grow flex flex-col h-full"
>
{({ values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
onDirty(hasChanges)
return (
<>
<SheetHeader>
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<div className="flex-grow overflow-y-auto">
<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-code-inline">{values.wrapper_name}_server</code>
</>
) : (
''
)
}
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col h-full"
>
<SheetHeader>
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<SheetSection className="flex-grow overflow-y-auto">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="wrapper_name"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Wrapper Name"
name="wrapper_name"
description={
wrapper_name.length > 0 ? (
<>
Your wrapper's server name will be{' '}
<code className="text-code-inline">{wrapper_name}_server</code>
</>
) : (
''
)
}
>
<FormControl_Shadcn_>
<Input_Shadcn_ id="wrapper_name" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<Separator />
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<Separator />
<FormSection header={<FormSectionLabel>Data target</FormSectionLabel>}>
<FormSectionContent loading={false} className="text-sm">
<RadioGroupStacked
value={selectedMode}
onValueChange={(value) => setSelectedMode(value as 'tables' | 'schema')}
>
<RadioGroupStackedItem
key="tables"
value="tables"
disabled={wrapperMeta.tables.length === 0}
label="Tables"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Create foreign tables to query data from {wrapperMeta.label}.
</p>
</div>
</div>
{wrapperMeta.tables.length === 0 ? (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs">
This wrapper doesn't support using foreign tables.
</span>
</div>
) : null}
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="schema"
value="schema"
disabled={
!wrapperMeta.canTargetSchema || !hasRequiredVersionForeignSchema
}
label="Schema"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Create all foreign tables from {wrapperMeta.label} in a specified
schema.
</p>
</div>
</div>
{wrapperMeta.canTargetSchema ? (
hasRequiredVersionForeignSchema ? null : (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs text-left">
This feature requires the{' '}
<span className="text-brand">wrappers</span> extension to be of
minimum version of 0.5.0.
</span>
</div>
)
) : (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs">
This wrapper doesn't support using a foreign schema.
</span>
</div>
)}
</RadioGroupStackedItem>
</RadioGroupStacked>
</FormSectionContent>
</FormSection>
<Separator />
{selectedMode === 'tables' && (
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light 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
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-foreground-light">
Columns:{' '}
{table.columns.map((column: any) => column.name).join(', ')}
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<CardContent key={option.name}>
<InputField option={option} control={form.control} />
</CardContent>
))}
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Data target</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<FormField_Shadcn_
control={form.control}
name="mode"
render={({ field }) => (
<FormItemLayout layout="vertical">
<FormControl_Shadcn_>
<RadioGroupStacked
value={field.value as string}
onValueChange={field.onChange}
>
<RadioGroupStackedItem
key="tables"
value="tables"
disabled={wrapperMeta.tables.length === 0}
label="Tables"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Create foreign tables to query data from {wrapperMeta.label}.
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
setNewTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
{wrapperMeta.tables.length === 0 ? (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs">
This wrapper doesn't support using foreign tables.
</span>
</div>
) : null}
</RadioGroupStackedItem>
<RadioGroupStackedItem
key="schema"
value="schema"
disabled={
!wrapperMeta.canTargetSchema || !hasRequiredVersionForeignSchema
}
label="Schema"
showIndicator={false}
>
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Create all foreign tables from {wrapperMeta.label} in a
specified schema.
</p>
</div>
</div>
))}
{wrapperMeta.canTargetSchema ? (
hasRequiredVersionForeignSchema ? null : (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs text-left">
This feature requires the{' '}
<span className="text-brand">wrappers</span> extension to be
of minimum version of 0.5.0.
</span>
</div>
)
) : (
<div className="w-full flex gap-x-2 py-2 items-center">
<WarningIcon />
<span className="text-xs">
This wrapper doesn't support using a foreign schema.
</span>
</div>
)}
</RadioGroupStackedItem>
</RadioGroupStacked>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</PageSectionContent>
</PageSection>
{mode === 'tables' && (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Tables</PageSectionTitle>
<PageSectionDescription>
You can query your data from these foreign tables after the wrapper is
created
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent className="flex flex-col space-y-2">
{tablesField.map((t, tableIndex) => {
// FIXME: make inference work
const table = t as unknown as FormattedWrapperTable
return (
<div
key={t.id}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-foreground-light">
Columns: {table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
)}
{newTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setSelectedTableToEdit(table)
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
removeTable(tableIndex)
}}
/>
</div>
)}
{newTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
)}
{selectedMode === 'schema' && (
<FormSection
header={
<FormSectionLabel>
<p>Foreign Schema</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from the foreign tables in the specified schema
after the wrapper is created.
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperMeta.sourceSchemaOption &&
!wrapperMeta.sourceSchemaOption?.readOnly && (
// Hide the field if the source schema is read-only
<div>
<InputField
key="source_schema"
option={wrapperMeta.sourceSchemaOption}
loading={false}
error={formErrors['source_schema']}
/>
<p className="text-foreground-lighter text-sm">
{wrapperMeta.sourceSchemaOption.description}
</p>
</div>
)}
<div className="flex flex-col gap-2">
<InputField
key="target_schema"
option={{
name: 'target_schema',
label: 'Specify a new schema to create all wrapper tables in',
required: true,
encrypted: false,
secureEntry: false,
}}
loading={false}
error={formErrors['target_schema']}
/>
<p className="text-foreground-lighter text-sm">
A new schema will be created. For security purposes, the wrapper tables
from the foreign schema cannot be created within an existing schema.
</p>
</div>
</FormSectionContent>
</FormSection>
)}
</div>
)
})}
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onCloseWithConfirmation}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
loading={isLoading}
>
Create wrapper
</Button>
</SheetFooter>
</>
)
}}
</Form>
<div className="flex justify-end">
<Button type="default" onClick={() => setSelectedTableToEdit(NewTable)}>
Add foreign table
</Button>
</div>
{tablesField.length === 0 && errors.tables && (
<p className="text-sm text-right text-red-900">
{errors.tables.message?.toString()}
</p>
)}
</PageSectionContent>
</PageSection>
)}
{mode === 'schema' && (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Schema</PageSectionTitle>
<PageSectionDescription>
You can query your data from the foreign tables in the specified schema
after the wrapper is created.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{wrapperMeta.sourceSchemaOption &&
!wrapperMeta.sourceSchemaOption?.readOnly && (
// Hide the field if the source schema is read-only
<InputField
key="source_schema"
option={wrapperMeta.sourceSchemaOption}
control={form.control}
/>
)}
<div className="flex flex-col gap-2">
<InputField
key="target_schema"
option={{
name: 'target_schema',
label: 'Specify a new schema to create all wrapper tables in',
description:
'A new schema will be created. For security purposes, the wrapper tables from the foreign schema cannot be created within an existing schema.',
required: true,
encrypted: false,
secureEntry: false,
}}
control={form.control}
/>
</div>
</PageSectionContent>
</PageSection>
)}
</SheetSection>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onCloseWithConfirmation}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isSubmitting || isLoading}
loading={isLoading}
>
Create wrapper
</Button>
</SheetFooter>
</form>
</Form_Shadcn_>
</div>
<WrapperTableEditor
visible={isEditingTable}
visible={selectedTableToEdit != null}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
@@ -1,10 +1,34 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQueryClient } from '@tanstack/react-query'
import { compact, isEmpty, mapValues } from 'lodash'
import { compact } from 'lodash'
import { Edit, Trash } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui'
import {
Button,
Card,
CardContent,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import * as z from 'zod'
import InputField from './InputField'
import { WrapperMeta } from './Wrappers.types'
@@ -12,15 +36,11 @@ import {
convertKVStringArrayToJson,
FormattedWrapperTable,
formatWrapperTables,
makeValidateRequired,
getEditionFormSchema,
NewTable,
} from './Wrappers.utils'
import WrapperTableEditor from './WrapperTableEditor'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import {
FormSection,
FormSectionContent,
FormSectionLabel,
} from '@/components/ui/Forms/FormSection'
import { invalidateSchemasQuery } from '@/data/database/schemas-query'
import { useFDWUpdateMutation } from '@/data/fdw/fdw-update-mutation'
import { FDW } from '@/data/fdw/fdws-query'
@@ -52,319 +72,286 @@ export const EditWrapperSheet = ({
const { mutate: updateFDW, isPending: isSaving } = useFDWUpdateMutation({
onSuccess: () => {
toast.success(`Successfully updated ${wrapperMeta?.label} foreign data wrapper`)
setWrapperTables([])
const hasNewSchema = wrapperTables.some((table) => table.is_new_schema)
const { tables } = getValues()
const hasNewSchema = (tables as Record<string, any>[]).some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
},
})
const [wrapperTables, setWrapperTables] = useState(() =>
formatWrapperTables(wrapper, wrapperMeta)
const initialValues: Record<string, any> = useMemo(
() => ({
wrapper_name: wrapper?.name,
server_name: wrapper?.server_name,
...convertKVStringArrayToJson(wrapper?.server_options ?? []),
tables: formatWrapperTables(wrapper, wrapperMeta),
}),
[wrapper, wrapperMeta]
)
const [isEditingTable, setIsEditingTable] = useState(false)
const formSchema = getEditionFormSchema(wrapperMeta)
type FormSchema = z.infer<typeof formSchema>
const form = useForm<FormSchema>({
defaultValues: initialValues,
resolver: zodResolver(formSchema),
})
const { getValues, reset, resetField, setError } = form
const { errors, isDirty, isSubmitting } = form.formState
const {
fields: tablesField,
append: appendTable,
remove: removeTable,
update: updateTable,
} = useFieldArray({
control: form.control,
name: 'tables',
})
const [selectedTableToEdit, setSelectedTableToEdit] = useState<FormattedWrapperTable | undefined>(
undefined
)
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const [isUpdateConfirmationOpen, setIsUpdateConfirmationOpen] = useState(false)
const [pendingFormState, setPendingFormState] = useState<Record<string, string> | null>(null)
const hasChangesRef = useRef(false)
const initialValues = {
wrapper_name: wrapper?.name,
server_name: wrapper?.server_name,
...convertKVStringArrayToJson(wrapper?.server_options ?? []),
}
const onUpdateTable = (values: FormattedWrapperTable) => {
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)
if (values.index !== undefined) {
updateTable(values.index, values)
} else {
appendTable(values)
}
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: Record<string, string>) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors = validate(values)
const { wrapper_name } = values
if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper'
if (!wrapperMeta.canTargetSchema && wrapperTables.length === 0)
errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) {
setFormErrors(errors)
const onSubmit: SubmitHandler<FormSchema> = async (values) => {
const { tables } = values
if (tables.length === 0) {
setError('tables', {
type: 'validate',
message: 'Please provide at least one table.',
})
return
}
setFormErrors({})
setPendingFormState({ ...values, server_name: `${wrapper_name}_server` })
setIsUpdateConfirmationOpen(true)
}
const checkIsDirty = useCallback(() => hasChangesRef.current, [])
const { confirmOnClose, modalProps } = useConfirmOnClose({
checkIsDirty,
checkIsDirty: () => isDirty,
onClose,
})
useEffect(() => {
if (!isClosing) return
if (checkIsDirty()) {
if (isDirty) {
confirmOnClose()
} else {
onClose()
}
setIsClosing(false)
}, [checkIsDirty, confirmOnClose, isClosing, onClose, setIsClosing])
}, [isDirty, confirmOnClose, isClosing, onClose, setIsClosing])
const wrapper_name = useWatch({ name: 'wrapper_name', control: form.control })
const [isLoadingSecrets, setIsLoadingSecrets] = useState(false)
useEffect(() => {
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
const encryptedIdsToFetch = compact(
encryptedOptions.map((option) => {
const value = initialValues[option.name]
return value ?? null
})
).filter((x) => UUID_REGEX.test(x))
// [Joshen] ^ Validate UUID to filter out already decrypted values
const fetchEncryptedValues = async (ids: string[]) => {
try {
setIsLoadingSecrets(true)
// If the secrets haven't loaded, escape and run the effect again when they're loaded
const decryptedValues = await getDecryptedValues({
projectRef: project?.ref,
connectionString: project?.connectionString,
ids: ids,
})
encryptedOptions.forEach((option) => {
const encryptedId = initialValues[option.name]
resetField(option.name, { defaultValue: decryptedValues[encryptedId] })
})
} catch (error) {
toast.error('Failed to fetch encrypted values')
} finally {
setIsLoadingSecrets(false)
}
}
if (encryptedIdsToFetch.length > 0) {
fetchEncryptedValues(encryptedIdsToFetch)
}
}, [initialValues, wrapperMeta, resetField, project?.ref, project?.connectionString])
return (
<>
<div className="flex flex-col h-full" tabIndex={-1}>
<Form
id={FORM_ID}
initialValues={initialValues}
onSubmit={onSubmit}
className="h-full flex flex-col"
>
{({
values,
initialValues,
resetForm,
}: {
values: Record<string, string>
initialValues: Record<string, string>
resetForm: (value: Record<string, Record<string, string>>) => void
}) => {
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loadingSecrets, setLoadingSecrets] = useState(false)
const initialTables = formatWrapperTables({
handler: wrapper.handler,
tables: wrapper?.tables ?? [],
})
const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables)
const hasChanges = hasFormChanges || hasTableChanges
hasChangesRef.current = hasChanges
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const fetchEncryptedValues = async (ids: string[]) => {
try {
setLoadingSecrets(true)
// If the secrets haven't loaded, escape and run the effect again when they're loaded
const decryptedValues = await getDecryptedValues({
projectRef: project?.ref,
connectionString: project?.connectionString,
ids: ids,
})
// replace all values which are in the decryptedValues object with the decrypted value
const transformValues = (values: Record<string, string>) => {
return mapValues(values, (value) => {
return decryptedValues[value] ?? value
})
}
resetForm({
values: transformValues(values),
initialValues: transformValues(initialValues),
})
} catch (error) {
toast.error('Failed to fetch encrypted values')
} finally {
setLoadingSecrets(false)
}
}
const encryptedOptions = wrapperMeta.server.options.filter(
(option) => option.encrypted
)
const encryptedIdsToFetch = compact(
encryptedOptions.map((option) => {
const value = initialValues[option.name]
return value ?? null
})
).filter((x) => UUID_REGEX.test(x))
// [Joshen] ^ Validate UUID to filter out already decrypted values
if (encryptedIdsToFetch.length > 0) {
fetchEncryptedValues(encryptedIdsToFetch)
}
/**
* [Joshen] We're deliberately not adding values and initialValues to the dependency array here
* as we only want to fetch the encrypted values once on load + values and initialValues will be updated
* as a result of that
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.ref, project?.connectionString])
return (
<>
<SheetHeader>
<SheetTitle>
Edit {wrapperMeta.label} wrapper: {wrapper.name}
</SheetTitle>
</SheetHeader>
<div className="flex-grow overflow-y-auto">
<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-code-inline">{values.wrapper_name}_server</code>
</>
) : (
<>
Your wrapper's server name is{' '}
<code className="text-code-inline">{values.wrapper_name}_server</code>
</>
)
}
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col h-full"
>
<SheetHeader>
<SheetTitle>
Edit {wrapperMeta.label} wrapper: {wrapper.name}
</SheetTitle>
</SheetHeader>
<SheetSection className="flex-grow overflow-y-auto">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="wrapper_name"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Wrapper Name"
description={
wrapper_name !== initialValues.wrapper_name ? (
<>
Your wrapper's server name will be updated to{' '}
<code className="text-code-inline">{wrapper_name}_server</code>
</>
) : (
<>
Your wrapper's server name is{' '}
<code className="text-code-inline">{wrapper_name}_server</code>
</>
)
}
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<CardContent key={option.name}>
<InputField
key={option.name}
option={option}
loading={option.encrypted ? loadingSecrets : false}
error={formErrors[option.name]}
control={form.control}
loading={option.secureEntry ? isLoadingSecrets : undefined}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light 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>
</CardContent>
))}
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Tables</PageSectionTitle>
<PageSectionDescription>
You can query your data from these foreign tables after the wrapper is created
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent className="flex flex-col space-y-2">
{tablesField.map((t, tableIndex) => {
// FIXME: make inference work
const table = t as unknown as FormattedWrapperTable
return (
<div
key={t.id}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-foreground-light">
Columns: {table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
) : (
<div className="space-y-2">
{wrapperTables.map((table, i) => {
const target = table?.table ?? table.object
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setSelectedTableToEdit(table)
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
removeTable(tableIndex)
}}
/>
</div>
</div>
)
})}
return (
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}{' '}
</p>
<p className="text-sm text-foreground-light mt-1">
Target: {target}
</p>
<p className="text-sm text-foreground-light">
Columns: {table.columns.map((column) => column.name).join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
{/* Wrappers which import foreign schema don't have tables and their tables can't be edited */}
{wrapperMeta.tables.length !== 0 && (
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
)}
<Button
type="default"
className="px-1"
icon={<Trash />}
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>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={confirmOnClose}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isSaving}
loading={isSaving}
>
Save wrapper
</Button>
</SheetFooter>
</>
)
}}
</Form>
<div className="flex justify-end">
<Button type="default" onClick={() => setSelectedTableToEdit(NewTable)}>
Add foreign table
</Button>
</div>
{tablesField.length === 0 && errors.tables && (
<p className="text-sm text-right text-red-900">
{errors.tables.message?.toString()}
</p>
)}
</PageSectionContent>
</PageSection>
</SheetSection>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={confirmOnClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isSubmitting || !isDirty}
loading={isSubmitting}
>
Save wrapper
</Button>
</SheetFooter>
</form>
</Form_Shadcn_>
</div>
<ConfirmationModal
@@ -377,21 +364,19 @@ export const EditWrapperSheet = ({
loading={isSaving}
onCancel={() => {
setIsUpdateConfirmationOpen(false)
setPendingFormState(null)
onClose()
}}
onConfirm={() => {
if (pendingFormState === null) return
const { tables, ...values } = getValues()
updateFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
wrapperMeta,
formState: pendingFormState,
tables: wrapperTables,
formState: values,
tables,
})
setIsUpdateConfirmationOpen(false)
setPendingFormState(null)
}}
>
<p className="text-sm text-foreground-light">
@@ -405,11 +390,10 @@ export const EditWrapperSheet = ({
<DiscardChangesConfirmationDialog {...modalProps} />
<WrapperTableEditor
visible={isEditingTable}
visible={selectedTableToEdit != null}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
@@ -1,34 +1,35 @@
import { Eye, EyeOff, HelpCircle, Loader } from 'lucide-react'
import { InputVariants } from '@ui/components/shadcn/ui/input'
import { HelpCircle } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { Button, Input } from 'ui'
import type { Control } from 'react-hook-form'
import { cn, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_, Textarea } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import type { ServerOption } from './Wrappers.types'
interface InputFieldProps {
option: ServerOption
loading: boolean
error: any
control: Control<any>
loading?: boolean
}
const InputField = ({ option, loading, error }: InputFieldProps) => {
const [showHidden, setShowHidden] = useState(!option.secureEntry)
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}
const InputField = ({ control, option, loading = false }: InputFieldProps) => {
return (
<FormField_Shadcn_
control={control}
name={option.name}
defaultValue={option.defaultValue ?? ''}
render={({ field }) => (
<FormItemLayout
name={option.name}
layout="vertical"
label={
<div className="flex items-center space-x-2">
<p>{option.label}</p>
{option.urlHelper !== undefined && (
<Link href={option.urlHelper} target="_blank" rel="noreferrer">
<span className="sr-only">Documentation</span>
<HelpCircle
strokeWidth={2}
size={14}
@@ -38,58 +39,26 @@ const InputField = ({ option, loading, error }: InputFieldProps) => {
)}
</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}
// The iceberg wrapper uses a dot in the option name, Formik has magic handling for arryas so we need to
// escape it in the name attribute. https://formik.org/docs/guides/arrays#avoid-nesting
name={`['${option.name}']`}
label={
<div className="flex items-center space-x-2">
<p>{option.label}</p>
{option.urlHelper !== undefined && (
<Link href={option.urlHelper} target="_blank" rel="noreferrer">
<HelpCircle
strokeWidth={2}
size={14}
className="text-foreground-light hover:text-foreground cursor-pointer transition"
/>
</Link>
labelOptional={!option.required ? 'Optional' : undefined}
description={option.description}
>
<FormControl_Shadcn_>
{loading ? (
<span className={cn(InputVariants({ size: 'small' }))}>
Fetching value from Vault...
</span>
) : option.isTextArea ? (
<Textarea {...field} id={option.name} rows={6} className="input-mono resize-none" />
) : option.secureEntry ? (
<Input copy reveal {...field} id={option.name} />
) : (
<Input_Shadcn_ {...field} id={option.name} />
)}
</div>
}
labelOptional={option.required ? undefined : 'Optional'}
defaultValue={option.defaultValue ?? ''}
error={error}
value={loading ? 'Fetching value from Vault...' : undefined}
type={!option.secureEntry || loading ? 'text' : showHidden ? 'text' : 'password'}
disabled={loading || option.readOnly}
actions={
loading ? (
<div className="flex items-center justify-center mr-1">
<Button disabled type="default" icon={<Loader className="animate-spin" />} />
</div>
) : option.secureEntry ? (
<div className="flex items-center justify-center mr-1">
<Button
type="default"
icon={showHidden ? <Eye /> : <EyeOff />}
onClick={() => setShowHidden(!showHidden)}
/>
</div>
) : null
}
/>
)
}
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
export default InputField
@@ -1,165 +0,0 @@
import { X } from 'lucide-react'
import { useEffect, useReducer } from 'react'
import { Button, Input } from 'ui'
import ColumnType from '@/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType'
import useLatest from '@/hooks/misc/useLatest'
export type SimpleColumn = {
id: number
name: string
type: string
}
export type WrapperDynamicColumnsProps = {
initialColumns?: Pick<SimpleColumn, 'name' | 'type'>[]
onChange?: (columns: SimpleColumn[]) => void
errors?: any
}
const DEFAULT_INITIAL_COLUMNS: WrapperDynamicColumnsProps['initialColumns'] = [
{ name: '', type: 'text' },
]
type State = {
columns: {
[key: number]: SimpleColumn
}
nextId: number
}
type Action =
| {
type: 'ADD_COLUMN'
}
| {
type: 'REMOVE_COLUMN'
payload: {
id: number
}
}
| {
type: 'UPDATE_COLUMN'
payload: {
id: number
key: keyof SimpleColumn
value: string
}
}
const WrapperDynamicColumns = ({
initialColumns = DEFAULT_INITIAL_COLUMNS,
onChange,
errors = {},
}: WrapperDynamicColumnsProps) => {
const [state, dispatch] = useReducer(
(state: State, action: Action) => {
switch (action.type) {
case 'ADD_COLUMN':
return {
...state,
columns: {
...state.columns,
[state.nextId]: { id: state.nextId, name: '', type: 'text' },
},
nextId: state.nextId + 1,
}
case 'REMOVE_COLUMN':
return {
...state,
columns: Object.fromEntries(
Object.entries(state.columns).filter(([key]) => Number(key) !== action.payload.id)
),
}
case 'UPDATE_COLUMN':
return {
...state,
columns: {
...state.columns,
[action.payload.id]: {
...state.columns[action.payload.id],
[action.payload.key]: action.payload.value,
},
},
}
default:
return state
}
},
{
columns: Object.fromEntries(
initialColumns.map((column, index) => [index, { ...column, id: index }])
),
nextId: initialColumns.length,
}
)
const onChangeRef = useLatest(onChange)
useEffect(() => {
onChangeRef.current?.(getColumns(state.columns))
}, [state.columns])
const onAddColumn = () => {
dispatch({ type: 'ADD_COLUMN' })
}
const onRemoveColumn = (id: number) => {
dispatch({ type: 'REMOVE_COLUMN', payload: { id } })
}
const onUpdateValue = (id: number, key: keyof SimpleColumn, value: string) => {
dispatch({ type: 'UPDATE_COLUMN', payload: { id, key, value } })
}
const columns = getColumns(state.columns)
return (
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col gap-4">
{columns.map((column, idx) => (
<div key={column.id} className="flex flex-col">
<div className="flex items-center gap-x-2">
<Input
className="flex-1 [&_label]:!p-0"
layout="vertical"
label="Name"
value={column.name}
onChange={(e) => onUpdateValue(column.id, 'name', e.target.value)}
/>
<div className="flex-1">
<ColumnType
value={column.type}
enumTypes={[]}
onOptionSelect={(value) => onUpdateValue(column.id, 'type', value)}
className="[&_label]:!p-0"
/>
</div>
<Button
type="outline"
icon={<X strokeWidth={1.5} />}
onClick={() => onRemoveColumn(column.id)}
className="self-end -translate-y-1.5 px-1.5"
/>
</div>
{errors[`columns.${idx}`] && (
<span className="text-red-900 text-sm mt-2">{errors[`columns.${idx}`]}</span>
)}
</div>
))}
</div>
<Button type="default" onClick={() => onAddColumn()} className="self-start">
Add column
</Button>
</div>
)
}
export default WrapperDynamicColumns
function getColumns(columns: State['columns']) {
return Object.values(columns).sort((a, b) => a.id - b.id)
}
@@ -35,7 +35,7 @@ export const WrapperTable = ({ isLatest = false }: WrapperTableProps) => {
const [isClosingEditWrapper, setIsClosingEditWrapper] = useState(false)
const { data, isSuccess } = useFDWsQuery({
const { data, isError } = useFDWsQuery({
projectRef: ref,
connectionString: project?.connectionString,
})
@@ -52,11 +52,11 @@ export const WrapperTable = ({ isLatest = false }: WrapperTableProps) => {
const selectedWrapperToEdit = wrappers.find((w) => w.id.toString() === selectedWrapperIdToEdit)
useEffect(() => {
if (isSuccess && !!selectedWrapperIdToEdit && !selectedWrapperToEdit) {
if (isError && !!selectedWrapperIdToEdit && !selectedWrapperToEdit) {
toast('Wrapper not found')
setSelectedWrapperToEdit(null)
}
}, [isSuccess, selectedWrapperIdToEdit, selectedWrapperToEdit, setSelectedWrapperToEdit])
}, [isError, selectedWrapperIdToEdit, selectedWrapperToEdit, setSelectedWrapperToEdit])
if (!integration || integration.type !== 'wrapper') {
return (
@@ -1,5 +1,14 @@
import { Check, ChevronsUpDown, Database, Plus } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Check, ChevronsUpDown, XIcon } from 'lucide-react'
import { useEffect, useId, useMemo, useState } from 'react'
import {
Control,
FieldValues,
SubmitHandler,
useFieldArray,
useForm,
useWatch,
} from 'react-hook-form'
import {
Button,
cn,
@@ -9,22 +18,37 @@ import {
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Form,
Input,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
Listbox,
Modal,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
ScrollArea,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectSeparator_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
SidePanel,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import * as z from 'zod'
import WrapperDynamicColumns from './WrapperDynamicColumns'
import type { Table, TableOption } from './Wrappers.types'
import { makeValidateRequired } from './Wrappers.utils'
import { ColumnType } from './ColumnType'
import type { AvailableColumn, Table, TableOption } from './Wrappers.types'
import { getTableFormSchema } from './Wrappers.utils'
import { ActionBar } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -38,8 +62,6 @@ export type WrapperTableEditorProps = {
initialData: any
}
type OnSubmitFn = (values: any, { resetForm }: { resetForm: () => void }) => void
const WrapperTableEditor = ({
visible,
onCancel,
@@ -52,7 +74,7 @@ const WrapperTableEditor = ({
const [selectedTableIndex, setSelectedTableIndex] = useState<string>('')
useEffect(() => {
if (initialData) {
if (initialData && Object.keys(initialData).length > 0) {
setSelectedTableIndex(String(initialData.index))
}
}, [initialData])
@@ -64,14 +86,13 @@ const WrapperTableEditor = ({
onCancel()
}
const onSubmit: OnSubmitFn = (values, { resetForm }) => {
const onSubmit: SubmitHandler<FieldValues> = (values) => {
onSave({
...values,
index: parseInt(selectedTableIndex),
schema_name: values.schema === 'custom' ? values.schema_name : values.schema,
is_new_schema: values.schema === 'custom',
})
resetForm()
setSelectedTableIndex('')
}
@@ -164,48 +185,48 @@ const WrapperTableEditor = ({
export default WrapperTableEditor
const Option = ({ option }: { option: TableOption }) => {
const Option = ({ option, control }: { option: TableOption; control: Control<FieldValues> }) => {
if (option.type === 'select') {
return (
<Listbox
key={option.name}
id={option.name}
<FormField_Shadcn_
control={control}
name={option.name}
label={option.label}
defaultValue={option.defaultValue ?? ''}
>
{[
...(!option.required
? [
<Listbox.Option key="empty" value="" label="---" className="!w-96">
---
</Listbox.Option>,
]
: []),
...option.options.map((subOption) => (
<Listbox.Option
key={subOption.value}
id={option.name + subOption.value}
value={subOption.value}
label={subOption.label}
className="!w-96"
>
{subOption.label}
</Listbox.Option>
)),
]}
</Listbox>
defaultValue={option.defaultValue}
render={({ field }) => (
<FormItemLayout layout="vertical" label={option.label} name={option.name}>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select an option" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectSeparator_Shadcn_ />
{option.options.map((subOption) => (
<SelectItem_Shadcn_ key={subOption.value} value={subOption.value}>
{subOption.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
return (
<Input
key={option.name}
id={option.name}
<FormField_Shadcn_
control={control}
name={option.name}
label={option.label}
placeholder={option.placeholder ?? ''}
defaultValue={option.defaultValue ?? ''}
render={({ field }) => (
<FormItemLayout layout="vertical" label={option.label} name={option.name}>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} id={option.name} placeholder={option.placeholder ?? ''} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
@@ -216,153 +237,285 @@ const TableForm = ({
initialData,
}: {
table: Table
onSubmit: OnSubmitFn
onSubmit: SubmitHandler<FieldValues>
initialData: any
}) => {
const { data: project } = useSelectedProjectQuery()
const {
data: schemas,
isPending: isLoading,
isSuccess,
} = useSchemasQuery({
const { data: schemas, isPending: isLoading } = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const requiredOptions =
table.options.filter((option) => option.editable && option.required && !option.defaultValue) ??
[]
const optionalOptions =
table.options.filter(
(option) => option.editable && (!option.required || option.defaultValue)
) ?? []
const requiredOptions: TableOption[] = []
const optionalOptions: TableOption[] = []
const nonEditableOptions: TableOption[] = []
const initialValues = initialData ?? {
table_name: '',
columns: table.availableColumns ?? [],
...Object.fromEntries(table.options.map((option) => [option.name, option.defaultValue ?? ''])),
schema: 'public',
schema_name: '',
table.options.forEach((option) => {
if (option.editable) {
if (option.required && !option.defaultValue) {
requiredOptions.push(option)
return
}
optionalOptions.push(option)
return
}
nonEditableOptions.push(option)
})
const defaultValues = useMemo(() => {
if (initialData && Object.keys(initialData).length > 0) {
const { schema } = initialData
const existingSchema = schemas?.find((s) => s.name === schema)
return {
schema_name: existingSchema ? '' : schema,
schema: existingSchema ? existingSchema.name : 'custom',
...Object.fromEntries(
table.options.map((option) => [option.name, option.defaultValue ?? ''])
),
...initialData,
}
}
return {
table_name: '',
columns: table.availableColumns ?? [],
schema: 'public',
...Object.fromEntries(
table.options.map((option) => [option.name, option.defaultValue ?? ''])
),
}
}, [initialData, table, schemas])
const formSchema = getTableFormSchema(table)
type FormSchema = z.infer<typeof formSchema>
const form = useForm<FormSchema>({
defaultValues,
resolver: zodResolver(formSchema),
shouldUnregister: true,
})
const {
fields: columnFields,
append: appendColumn,
replace: replaceColumns,
remove: removeColumn,
} = useFieldArray({
control: form.control,
name: 'columns',
})
const { reset } = form
useEffect(() => {
reset(defaultValues)
// Workaround bug in react-hook-form
replaceColumns(defaultValues.columns ?? [])
}, [reset, replaceColumns, defaultValues])
const handleSubmit: SubmitHandler<FieldValues> = (values) => {
const { schema_name, schema, ...valuesWithoutSchema } = values
onSubmit({
...valuesWithoutSchema,
// Ensure all options are accounted for.
...Object.fromEntries(
table.options.map((option) => [
option.name,
values[option.name] ?? option.defaultValue ?? '',
])
),
schema,
schema_name: schema === 'custom' ? schema_name : schema,
is_new_schema: schema === 'custom',
})
reset()
}
const validate = makeValidateRequired([
...table.options,
{ name: 'table_name', required: true },
{ name: 'columns', required: true },
...(table.availableColumns ? [] : [{ name: 'columns.name', required: true }]),
])
const { errors } = form.formState
const schema = useWatch({ name: 'schema', control: form.control })
return (
<Form
id="wrapper-table-editor-form"
initialValues={initialValues}
validate={validate}
onSubmit={onSubmit}
enableReinitialize={true}
>
{({ errors, values, setFieldValue }: any) => {
return (
<div className="space-y-4">
{isLoading && <ShimmeringLoader className="py-4" />}
<Form_Shadcn_ {...form}>
<form
id="wrapper-table-editor-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
{isLoading && <ShimmeringLoader className="py-4" />}
{isSuccess && (
<Listbox size="small" name="schema" label="Select a schema for the foreign table">
<Listbox.Option
key="custom"
id="custom"
label={`Create a new schema`}
value="custom"
addOnBefore={() => <Plus size={16} strokeWidth={1.5} />}
<FormField_Shadcn_
control={form.control}
name="schema"
render={({ field }) => (
<FormItemLayout layout="vertical" label="Select a schema for the foreign table">
<FormControl_Shadcn_>
<Select_Shadcn_
name="schema"
value={field.value}
onValueChange={(schema) => {
field.onChange(schema)
form.resetField('schema_name')
}}
>
Create a new schema
</Listbox.Option>
<Modal.Separator />
{(schemas ?? [])?.map((schema) => {
return (
<Listbox.Option
key={schema.id}
id={schema.name}
label={schema.name}
value={schema.name}
addOnBefore={() => <Database size={16} strokeWidth={1.5} />}
>
{schema.name}
</Listbox.Option>
)
})}
</Listbox>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select an option" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="custom">Create a new schema</SelectItem_Shadcn_>
<SelectSeparator_Shadcn_ />
{(schemas ?? [])?.map((schema) => {
return (
<SelectItem_Shadcn_ key={schema.name} value={schema.name}>
{schema.name}
</SelectItem_Shadcn_>
)
})}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{schema === 'custom' && (
<FormField_Shadcn_
control={form.control}
name="schema_name"
render={({ field }) => (
<FormItemLayout name="schema_name" layout="vertical" label="Schema name">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} id="schema_name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
{values.schema === 'custom' && (
<Input id="schema_name" name="schema_name" label="Schema name" />
)}
<Input
id="table_name"
<FormField_Shadcn_
control={form.control}
name="table_name"
render={({ field }) => (
<FormItemLayout
layout="vertical"
name="table_name"
label="Table name"
descriptionText="You can query from this table after the wrapper is enabled."
/>
{requiredOptions.map((option) => (
<Option key={option.name} option={option} />
))}
description="You can query from this table after the wrapper is enabled."
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} id="table_name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{requiredOptions.map((option) => (
<Option key={option.name} option={option} control={form.control} />
))}
{nonEditableOptions.map((option) => (
<input key={option.name} type="hidden" {...form.register(option.name)} />
))}
{table.availableColumns != null ? (
<FormField_Shadcn_
control={form.control}
name="selected_columns"
render={() => (
<FormItemLayout
layout="vertical"
label="Select the columns to be added to your table."
>
<div>
<MultiSelector
onValuesChange={(selectedColumns) => {
const newColumnFieldsValue: AvailableColumn[] = []
<div className="form-group">
<label className="!w-full">
{table.availableColumns
? 'Select the columns to be added to your table'
: 'Add columns to your table'}
</label>
<div className="flex flex-wrap gap-2 w-full">
{table.availableColumns ? (
table.availableColumns.map((column) => {
const isSelected = Boolean(
values.columns.find((col: any) => col.name === column.name)
)
return (
<div
key={column.name}
className={[
'px-2 py-1 rounded cursor-pointer transition',
`${isSelected ? 'bg-brand-300' : 'bg-surface-300 hover:bg-selection'}`,
].join(' ')}
onClick={() => {
if (isSelected) {
setFieldValue(
'columns',
values.columns.filter((col: any) => col.name !== column.name)
)
} else {
setFieldValue('columns', values.columns.concat([column]))
}
}}
>
<p className="text-sm">{column.name}</p>
</div>
)
})
) : (
<WrapperDynamicColumns
initialColumns={values.columns}
onChange={(columns) => {
setFieldValue('columns', columns)
table.availableColumns!.forEach((availableColumn) => {
if (selectedColumns.includes(availableColumn.name)) {
newColumnFieldsValue.push(availableColumn)
}
})
replaceColumns(newColumnFieldsValue)
}}
errors={errors}
/>
)}
values={columnFields.map(
(column) =>
// @ts-expect-error FIXME: cannot make inference work properly
column.name
)}
size="small"
className="w-full"
>
<MultiSelectorTrigger
mode="inline-combobox"
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full !min-w-lg"
/>
<MultiSelectorContent>
<MultiSelectorList>
{table.availableColumns!.map((availableColumn, index) => (
<MultiSelectorItem
key={availableColumn.name}
value={availableColumn.name}
>
{availableColumn.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
</FormItemLayout>
)}
/>
) : (
<div className="flex flex-col gap-y-2">
{columnFields.map((column, columnIndex) => (
<div key={column.id} className="flex items-center gap-x-2">
<FormField_Shadcn_
control={form.control}
name={`columns.${columnIndex}.name`}
render={({ field }) => (
<FormItemLayout
layout="vertical"
name={`columns.${columnIndex}.name`}
label="Name"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} id={`columns.${columnIndex}.name`} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<ColumnType
control={form.control}
className="w-1/2"
name={`columns.${columnIndex}.type`}
enumTypes={[]}
/>
<Button
type="outline"
icon={<XIcon strokeWidth={1.5} />}
onClick={() => removeColumn(columnIndex)}
className="self-end -translate-y-1.5 px-1.5"
// @ts-expect-error FIXME: cannot make inference work
aria-label={`Remove column ${column.name}`}
/>
</div>
{errors.columns && (
<span className="text-red-900 text-sm mt-2">{errors.columns}</span>
)}
</div>
{optionalOptions.map((option) => (
<Option key={option.name} option={option} />
))}
<Button
type="default"
onClick={() => appendColumn({ name: '', type: 'text' })}
className="self-start"
>
Add column
</Button>
{errors.columns != null && errors.columns.message != null && (
<span className="text-red-900 text-sm mt-2">{errors.columns.message.toString()}</span>
)}
</div>
)
}}
</Form>
)}
{optionalOptions.map((option) => (
<Option key={option.name} option={option} control={form.control} />
))}
</form>
</Form_Shadcn_>
)
}
@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest'
import { SUPABASE_TARGET_SCHEMA_OPTION, WRAPPER_HANDLERS, WRAPPERS } from './Wrappers.constants'
import { Table } from './Wrappers.types'
import {
getEditionFormSchema,
getTableFormSchema,
getWrapperCreationFormSchema,
} from './Wrappers.utils'
const stripe_wrapper = {
name: 'stripe_wrapper',
handlerName: WRAPPER_HANDLERS.STRIPE,
validatorName: 'stripe_fdw_validator',
icon: '',
description: 'Payment processing and subscription management',
extensionName: 'StripeFdw',
label: 'Stripe',
docsUrl: '',
server: {
options: [
{
name: 'api_key_id',
label: 'Stripe Secret Key',
required: true,
encrypted: true,
secureEntry: true,
urlHelper: 'https://stripe.com/docs/keys',
},
{
name: 'api_url',
label: 'Stripe API URL',
defaultValue: 'https://api.stripe.com/v1',
required: false,
encrypted: false,
secureEntry: false,
},
SUPABASE_TARGET_SCHEMA_OPTION,
],
},
tables: [],
}
describe('getWrapperCreationFormSchema', () => {
it('should build a schema that validates tables mode', () => {
const schema = getWrapperCreationFormSchema(stripe_wrapper)
const result = schema.safeParse({ mode: 'tables' })
expect(result.success).toEqual(false)
// Common required field
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'wrapper_name'))
).toBeDefined()
// Table mode specific field
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'tables'))
).toBeDefined()
})
it('should build a schema that validates schema mode', () => {
const schema = getWrapperCreationFormSchema(stripe_wrapper)
const result = schema.safeParse({ mode: 'schema' })
expect(result.success).toEqual(false)
// Common required field
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'wrapper_name'))
).toBeDefined()
// Schema mode specific field
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'source_schema'))
).toBeDefined()
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'target_schema'))
).toBeDefined()
})
it('should build a schema that validates wrapper specific options', () => {
const schema = getWrapperCreationFormSchema(stripe_wrapper)
const result = schema.safeParse({ mode: 'tables' })
expect(result.success).toEqual(false)
// Required option
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_key_id'))
).toBeDefined()
// Optional option
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_url'))
).toBeUndefined()
})
})
describe('getEditionFormSchema', () => {
it('should build a schema that validates wrapper specific options', () => {
const schema = getEditionFormSchema(stripe_wrapper)
const result = schema.safeParse({ mode: 'tables' })
expect(result.success).toEqual(false)
// Common required field
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'wrapper_name'))
).toBeDefined()
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'tables'))
).toBeDefined()
// Required option
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_key_id'))
).toBeDefined()
// Optional option
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_url'))
).toBeUndefined()
})
})
describe('getTableFormSchema', () => {
const table_schema = {
label: 'Accounts',
description: 'List of accounts on your Stripe account',
availableColumns: [
{
name: 'id',
type: 'text',
},
{
name: 'business_type',
type: 'text',
},
{
name: 'country',
type: 'text',
},
{
name: 'email',
type: 'text',
},
{
name: 'type',
type: 'text',
},
{
name: 'created',
type: 'timestamp',
},
{
name: 'attrs',
type: 'jsonb',
},
],
options: [
{
name: 'object',
defaultValue: 'accounts',
editable: false,
required: true,
type: 'text',
},
{
name: 'rowid_column',
label: 'Row ID Column',
defaultValue: 'id',
editable: true,
required: false,
type: 'text',
},
],
} satisfies Table
it('should build a schema that validates tables with existing schema', () => {
const schema = getTableFormSchema(table_schema)
const result = schema.safeParse({})
expect(result.success).toEqual(false)
// Common required field
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'table_name'))
).toBeDefined()
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'schema'))
).toBeDefined()
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'columns'))
).toBeDefined()
// Required options
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'object'))
).toBeDefined()
// Optional options
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'rowid_column'))
).toBeUndefined()
})
it('should build a schema that validates tables with custom schema', () => {
const schema = getTableFormSchema(table_schema)
// Because the schema_name is validated in zod superRefine, the normal validation
// must have succeeded first
const result = schema.safeParse({
table_name: 'test',
schema: 'custom',
columns: [],
object: 'acounts',
})
expect(result.success).toEqual(false)
expect(
result.error?.issues.find((issue) => issue.path.some((p) => p === 'schema_name'))
).toBeDefined()
})
})
@@ -1,7 +1,113 @@
import * as z from 'zod'
import { WRAPPER_HANDLERS, WRAPPERS } from './Wrappers.constants'
import type { WrapperMeta } from './Wrappers.types'
import type { Table, WrapperMeta } from './Wrappers.types'
import { FDW, FDWTable } from '@/data/fdw/fdws-query'
const tableSchema = z
.object({
index: z.number(),
columns: z.array(z.object({ name: z.string(), type: z.string() })),
is_new_schema: z.boolean(),
schema: z.string(),
schema_name: z.string(),
table_name: z.string(),
object: z.any().optional(),
})
.passthrough() // passthrough is needed for table options
export const getWrapperCreationFormSchema = (wrapperMeta: WrapperMeta) => {
let wrapperSchema = {
// Common validation for all wrappers
wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
} as Record<string, any>
// Add wrapper specific options
wrapperMeta.server.options.forEach((option) => {
if (option.required) {
wrapperSchema[option.name] = z.string().min(1, 'Required')
return
}
wrapperSchema[option.name] = z.string().optional()
})
return z.discriminatedUnion('mode', [
z
.object({
mode: z.literal('tables'),
tables: z
.array(tableSchema, { required_error: 'Please provide at least one table' })
.min(1, 'Please provide at least one table'),
})
.merge(z.object(wrapperSchema)),
z
.object({
mode: z.literal('schema'),
source_schema: z.string().min(1, 'Please provide a source schema'),
target_schema: z.string().min(1, 'Please provide an unique target schema'),
})
.merge(z.object(wrapperSchema)),
])
}
export const getEditionFormSchema = (wrapperMeta: WrapperMeta) => {
let wrapperSchema = {
// Common validation for all wrappers
wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
tables: z
.array(tableSchema, { required_error: 'Please provide at least one table' })
.min(1, 'Please provide at least one table'),
} as Record<string, any>
// Add wrapper specific options
wrapperMeta.server.options.forEach((option) => {
if (option.required) {
wrapperSchema[option.name] = z.string().min(1, 'Required')
return
}
wrapperSchema[option.name] = z.string().optional()
})
return z.object(wrapperSchema)
}
export const getTableFormSchema = (table: Table) => {
let tableSchema = {
table_name: z.string().min(1, 'Required'),
schema: z.string().min(1, 'Required'),
schema_name: z.string().optional(),
columns: z.array(
z.object({
name: z.string().min(1, 'Required'),
type: z.string().min(1, 'Required'),
})
),
} as Record<string, any>
table.options.forEach((option) => {
if (option.required) {
tableSchema[option.name] = z.string().min(1, 'Required')
return
}
tableSchema[option.name] = z.string().optional()
})
return (
z
.object(tableSchema)
// passthrough is needed for table options
.passthrough()
.superRefine((values, ctx) => {
if (values.schema === 'custom' && !values.schema_name) {
ctx.addIssue({
code: 'custom',
path: ['schema_name'],
message: 'Required',
})
}
})
)
}
export const makeValidateRequired = (options: { name: string; required: boolean }[]) => {
const requiredOptionsSet = new Set(
options.filter((option) => option.required).map((option) => option.name)
@@ -50,6 +156,8 @@ export const makeValidateRequired = (options: { name: string; required: boolean
}
}
export const NewTable = {} as FormattedWrapperTable
export interface FormattedWrapperTable {
index: number
columns: { name: string }[]
+142
View File
@@ -0,0 +1,142 @@
import { expect } from '@playwright/test'
import { env } from '../env.config.js'
import { query } from '../utils/db/client.js'
import { test, withSetupCleanup } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
const testRunner = env.IS_PLATFORM ? test.describe.serial : test.describe
testRunner('Stripe', () => {
test('can create a stripe wrapper with schema', async ({ page, ref }) => {
const wrapperName = 'stripe_schema'
const schemaName = 'stripe'
await using _ = await withSetupCleanup(
async () => {
await query(`
create schema if not exists extensions;
create extension if not exists wrappers
schema extensions
version '0.5.7'
cascade;
`)
},
async () => {
// Make the request in the page context so that the it can delete wrappers that belongs to supabase_admin
await page.request.post(toUrl(`/api/platform/pg-meta/${ref}/query`), {
failOnStatusCode: true,
data: {
query: `
drop foreign data wrapper if exists ${wrapperName} cascade;
delete from vault.secrets where name = '${wrapperName}_api_key_id';
drop schema ${schemaName} cascade;`,
},
})
}
)
await page.goto(toUrl(`/project/${ref}/integrations/stripe_wrapper/overview`))
await page.getByRole('button', { name: 'Add new wrapper' }).click()
await page.getByRole('textbox', { name: 'Wrapper Name' }).fill(wrapperName)
await page.getByRole('textbox', { name: 'Stripe Secret Key' }).fill('my secret')
await page.getByRole('radio', { name: 'Schema' }).click()
await page
.getByRole('textbox', { name: 'Specify a new schema to create all wrapper tables in' })
.fill(schemaName)
await page.getByRole('button', { name: 'Create wrapper' }).click()
await expect(page.getByText('Successfully created Stripe foreign data wrapper')).toBeVisible()
})
test('can create a stripe wrapper with tables', async ({ page, ref }) => {
const wrapperName = 'stripe_tables'
const tableName = 'stripe_accounts'
await using _ = await withSetupCleanup(
async () => {
await query(`
create schema if not exists extensions;
create extension if not exists wrappers
schema extensions
version '0.5.7'
cascade;
`)
},
async () => {
// Make the request in the page context so that the it can delete wrappers that belongs to supabase_admin
await page.request.post(toUrl(`/api/platform/pg-meta/${ref}/query`), {
failOnStatusCode: true,
data: {
query: `
drop foreign data wrapper if exists ${wrapperName} cascade;
delete from vault.secrets where name = '${wrapperName}_api_key_id';
drop table if exists public.${tableName};`,
},
})
}
)
await page.goto(toUrl(`/project/${ref}/integrations/stripe_wrapper/overview`))
await page.getByRole('button', { name: 'Add new wrapper' }).click()
await page.getByRole('textbox', { name: 'Wrapper Name' }).fill(wrapperName)
await page.getByRole('textbox', { name: 'Stripe Secret Key' }).fill('my secret')
await page.getByRole('button', { name: 'Add foreign table' }).click()
await page.getByRole('combobox').click()
await page.getByRole('option', { name: 'Accounts List of accounts on' }).click()
await page.getByRole('textbox', { name: 'Table name' }).fill(tableName)
await page.getByRole('button', { name: 'Save' }).click()
await page.getByRole('button', { name: 'Create wrapper' }).click()
await expect(page.getByText('Successfully created Stripe foreign data wrapper')).toBeVisible()
})
})
testRunner('S3 Wrapper', () => {
test('can create an S3 wrapper', async ({ page, ref }) => {
const wrapperName = 'test_s3_wrapper'
const tableName = 'test_s3_wrapper_table'
await using _ = await withSetupCleanup(
async () => {
await query(`
create schema if not exists extensions;
create extension if not exists wrappers
schema extensions
version '0.5.7'
cascade;
`)
},
async () => {
// Make the request in the page context so that the it can delete wrappers that belongs to supabase_admin
await page.request.post(toUrl(`/api/platform/pg-meta/${ref}/query`), {
failOnStatusCode: true,
data: {
query: `
drop foreign data wrapper if exists ${wrapperName} cascade;
delete from vault.secrets where name = '${wrapperName}_vault_access_key_id';
delete from vault.secrets where name = '${wrapperName}_vault_secret_access_key';
drop table if exists public.${tableName};`,
},
})
}
)
await page.goto(toUrl(`/project/${ref}/integrations/s3_wrapper/overview`))
await page.getByRole('button', { name: 'Add new wrapper' }).click()
await page.getByRole('textbox', { name: 'Wrapper Name' }).fill(wrapperName)
await page.getByRole('textbox', { name: 'Access Key ID' }).fill('s3 access id')
await page.getByRole('textbox', { name: 'Access Key Secret' }).fill('s3 access secret')
await page.getByRole('button', { name: 'Add foreign table' }).click()
await page.getByRole('combobox').click()
await page.getByRole('option', { name: 'S3 File' }).click()
await page.getByRole('textbox', { name: 'Table name' }).fill(tableName)
await page.getByRole('textbox', { name: 'URI' }).fill('s3://bucket/s3_table.csv')
await page.getByRole('button', { name: 'Add column' }).click()
// FIXME: Necessary because this component is somehow remounted
await page.waitForTimeout(500)
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('s3_column')
await page.getByRole('button', { name: 'Save' }).click()
await page.getByRole('button', { name: 'Create wrapper' }).click()
await expect(page.getByText('Successfully created S3 foreign data wrapper')).toBeVisible()
})
})