mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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_>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+334
-237
@@ -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 }[]
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user