diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/ColumnType.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/ColumnType.tsx new file mode 100644 index 00000000000..5089feeb2cd --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/ColumnType.tsx @@ -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 + case 'time': + return + case 'text': + return + case 'json': + return ( +
+ {'{ }'} +
+ ) + case 'jsonb': + return ( +
+ {'{ }'} +
+ ) + case 'bool': + return + default: + return + } + } + + return ( + { + const isAvailableType = field.value ? availableTypes.includes(field.value) : true + const recommendation = RECOMMENDED_ALTERNATIVE_DATA_TYPE[field.value] + + if (!isAvailableType) { + return ( + + + + + + ) + } + return ( + + + Type + + + + + + + + + Type not found. + + + + + {POSTGRES_DATA_TYPE_OPTIONS.map((option: PostgresDataTypeOption) => ( + { + field.onChange(value) + setOpen(false) + }} + > +
+ {inferIcon(option.type)} + {option.name} + {option.description} +
+ + {option.name === field.value ? ( + + ) : ( + '' + )} + +
+ ))} +
+ + {enumTypes.length > 0 && ( + <> + + + {enumTypes.map((option) => ( + { + // [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) + }} + > +
+
+ +
+ + {option.format.replaceAll('"', '')} + + {option.comment !== undefined && ( + + {option.comment} + + )} + {option.format === field.value && ( + + + + )} +
+
+ ))} +
+ + )} +
+
+
+
+
+ {showRecommendation && recommendation !== undefined && ( + + + + {' '} + It is recommended to use{' '} + {recommendation.alternative} instead + + +

+ Postgres recommends against using the data type{' '} + {field.value} unless you have a very + specific use case. +

+
+ + +
+
+
+ )} +
+ ) + }} + /> + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx index bab2e1450f7..2de11fa2ca6 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx @@ -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 = { +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 + +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 + +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 + +const formSchema = z.discriminatedUnion('target', [ + S3TableSchema, + R2CatalogSchema, + IcebergRestCatalogSchema, +]) + +type FormSchema = z.infer + +const targetFields: Record = { S3Tables: [ { name: 'vault_aws_access_key_id', required: true }, { name: 'vault_aws_secret_access_key', required: true }, @@ -59,8 +118,19 @@ const requiredFields: Record = { 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('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({ + 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(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 = 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 = {} + 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 ( <>
-
- {({ values, initialValues }: any) => { - const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) - onDirty(hasChanges) - - return ( - <> - - Create a {wrapperMeta.label} wrapper - -
- Wrapper Configuration}> - - 0 ? ( - <> - Your wrapper's server name will be{' '} - {values.wrapper_name}_server - - ) : ( - '' - ) - } + + + + Create a {wrapperMeta.label} wrapper + + + + + + Wrapper Configuration + + + + + + ( + 0 ? ( + <> + Your wrapper's server name will be{' '} + {wrapperName}_server + + ) : ( + '' + ) + } + > + + + + + )} /> - - - - Data target}> - - setSelectedTarget(value as Target)} - > - -
-
-

- AWS S3 storage that's optimized for analytics workloads. -

+ + + + + + + + Data target + + + + + + ( + +
+ + +
+
+

+ AWS S3 storage that's optimized for analytics workloads. +

+
+
+
+ +
+
+

+ Managed Apache Iceberg built directly into your R2 bucket. +

+
+
+
+ +
+
+

+ Can be used with any S3-compatible storage. +

+
+
+
+
-
- - -
-
-

- Managed Apache Iceberg built directly into your R2 bucket. -

-
-
-
- -
-
-

- Can be used with any S3-compatible storage. -

-
-
-
- - - - {wrapperMeta.label} Configuration} - > - - {wrapperMeta.server.options - .filter((option) => !option.hidden) - .map((option) => ( - - ))} - - - - -

Foreign Schema

-

- You can query your data from the foreign tables in the specified schema - after the wrapper is created. -

- - } - > - - {wrapperMeta.sourceSchemaOption && ( -
- -

- {wrapperMeta.sourceSchemaOption.description} -

-
- )} -
- -

- A new schema will be created. For security purposes, the wrapper tables - from the foreign schema cannot be created within an existing schema. -

-
-
-
-
+ + )} + /> + + + + - - - - - - ) - }} - + + + + {wrapperMeta.label} Configuration + + + + + {targetOptions.map((option) => + option.hidden ? ( + + ) : ( + + + + ) + )} + + + + + + + Foreign Schema + + You can query your data from the foreign tables in the specified schema after + the wrapper is created. + + + + + + + {wrapperMeta.sourceSchemaOption && ( + + )} + + + + + + + + + + + + + + +
) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index d7b95acceaf..db484468604 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -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([]) - 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, } + const formSchema = getWrapperCreationFormSchema(wrapperMeta) + type FormSchema = z.infer + const form = useForm({ + 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[]).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 = 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 ( <>
-
- {({ values, initialValues }: any) => { - const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) - onDirty(hasChanges) - - return ( - <> - - Create a {wrapperMeta.label} wrapper - -
- Wrapper Configuration}> - - 0 ? ( - <> - Your wrapper's server name will be{' '} - {values.wrapper_name}_server - - ) : ( - '' - ) - } + + + + Create a {wrapperMeta.label} wrapper + + + + + + Wrapper Configuration + + + + + + ( + 0 ? ( + <> + Your wrapper's server name will be{' '} + {wrapper_name}_server + + ) : ( + '' + ) + } + > + + + + + )} /> - - - - {wrapperMeta.label} Configuration} - > - - {wrapperMeta.server.options - .filter((option) => !option.hidden) - .map((option) => ( - - ))} - - - - Data target}> - - setSelectedMode(value as 'tables' | 'schema')} - > - -
-
-

- Create foreign tables to query data from {wrapperMeta.label}. -

-
-
- {wrapperMeta.tables.length === 0 ? ( -
- - - This wrapper doesn't support using foreign tables. - -
- ) : null} -
- -
-
-

- Create all foreign tables from {wrapperMeta.label} in a specified - schema. -

-
-
- {wrapperMeta.canTargetSchema ? ( - hasRequiredVersionForeignSchema ? null : ( -
- - - This feature requires the{' '} - wrappers extension to be of - minimum version of 0.5.0. - -
- ) - ) : ( -
- - - This wrapper doesn't support using a foreign schema. - -
- )} -
-
-
-
- - {selectedMode === 'tables' && ( - -

Foreign Tables

-

- You can query your data from these foreign tables after the wrapper is - created -

- - } - > - - {newTables.length === 0 ? ( -
- -
- ) : ( -
- {newTables.map((table, i) => ( -
-
-

- {table.schema_name}.{table.table_name} -

-

- Columns:{' '} - {table.columns.map((column: any) => column.name).join(', ')} + + + + + + + + {wrapperMeta.label} Configuration + + + + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + + + + ))} + + + + + + + Data target + + + + ( + + + + +

+
+

+ Create foreign tables to query data from {wrapperMeta.label}.

-
-
+ {wrapperMeta.tables.length === 0 ? ( +
+ + + This wrapper doesn't support using foreign tables. + +
+ ) : null} + + +
+
+

+ Create all foreign tables from {wrapperMeta.label} in a + specified schema. +

- ))} + {wrapperMeta.canTargetSchema ? ( + hasRequiredVersionForeignSchema ? null : ( +
+ + + This feature requires the{' '} + wrappers extension to be + of minimum version of 0.5.0. + +
+ ) + ) : ( +
+ + + This wrapper doesn't support using a foreign schema. + +
+ )} +
+ + + + )} + /> + + + {mode === 'tables' && ( + + + + Foreign Tables + + You can query your data from these foreign tables after the wrapper is + created + + + + + {tablesField.map((t, tableIndex) => { + // FIXME: make inference work + const table = t as unknown as FormattedWrapperTable + return ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ Columns: {table.columns.map((column: any) => column.name).join(', ')} +

- )} - {newTables.length > 0 && ( -
- +
+
- )} - {newTables.length === 0 && formErrors.tables && ( -

{formErrors.tables}

- )} - - - )} - {selectedMode === 'schema' && ( - -

Foreign Schema

-

- You can query your data from the foreign tables in the specified schema - after the wrapper is created. -

- - } - > - - {wrapperMeta.sourceSchemaOption && - !wrapperMeta.sourceSchemaOption?.readOnly && ( - // Hide the field if the source schema is read-only -
- -

- {wrapperMeta.sourceSchemaOption.description} -

-
- )} -
- -

- A new schema will be created. For security purposes, the wrapper tables - from the foreign schema cannot be created within an existing schema. -

-
-
- )} -
+ ) + })} - - - - - - ) - }} - +
+ +
+ {tablesField.length === 0 && errors.tables && ( +

+ {errors.tables.message?.toString()} +

+ )} + + + )} + {mode === 'schema' && ( + + + + Foreign Schema + + You can query your data from the foreign tables in the specified schema + after the wrapper is created. + + + + + {wrapperMeta.sourceSchemaOption && + !wrapperMeta.sourceSchemaOption?.readOnly && ( + // Hide the field if the source schema is read-only + + )} +
+ +
+
+
+ )} + + + + + + +
{ setSelectedTableToEdit(undefined) - setIsEditingTable(false) }} onSave={onUpdateTable} initialData={selectedTableToEdit} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx index f9bb04d32d1..91da61224fc 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx @@ -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[]).some((table) => table.is_new_schema) if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref) }, }) - const [wrapperTables, setWrapperTables] = useState(() => - formatWrapperTables(wrapper, wrapperMeta) + const initialValues: Record = 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 + const form = useForm({ + 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( undefined ) - const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) const [isUpdateConfirmationOpen, setIsUpdateConfirmationOpen] = useState(false) - const [pendingFormState, setPendingFormState] = useState | 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) => { - 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 = 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 ( <>
-
- {({ - values, - initialValues, - resetForm, - }: { - values: Record - initialValues: Record - resetForm: (value: Record>) => 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) => { - 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 ( - <> - - - Edit {wrapperMeta.label} wrapper: {wrapper.name} - - -
- Wrapper Configuration}> - - - Your wrapper's server name will be updated to{' '} - {values.wrapper_name}_server - - ) : ( - <> - Your wrapper's server name is{' '} - {values.wrapper_name}_server - - ) - } + + + + + Edit {wrapperMeta.label} wrapper: {wrapper.name} + + + + + + + Wrapper Configuration + + + + + + ( + + Your wrapper's server name will be updated to{' '} + {wrapper_name}_server + + ) : ( + <> + Your wrapper's server name is{' '} + {wrapper_name}_server + + ) + } + > + + + + + )} /> - - - {wrapperMeta.label} Configuration} - > - - {wrapperMeta.server.options - .filter((option) => !option.hidden) - .map((option) => ( + + + + + + + + {wrapperMeta.label} Configuration + + + + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + - ))} - - - -

Foreign Tables

-

- You can query your data from these foreign tables after the wrapper is - created -

- - } - > - - {wrapperTables.length === 0 ? ( -
- + + ))} + + + + + + + Foreign Tables + + You can query your data from these foreign tables after the wrapper is created + + + + + {tablesField.map((t, tableIndex) => { + // FIXME: make inference work + const table = t as unknown as FormattedWrapperTable + return ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ Columns: {table.columns.map((column: any) => column.name).join(', ')} +

- ) : ( -
- {wrapperTables.map((table, i) => { - const target = table?.table ?? table.object +
+
+
+ ) + })} - return ( -
-
-

- {table.schema_name}.{table.table_name}{' '} -

-

- Target: {target} -

-

- Columns: {table.columns.map((column) => column.name).join(', ')} -

-
-
- {/* Wrappers which import foreign schema don't have tables and their tables can't be edited */} - {wrapperMeta.tables.length !== 0 && ( -
-
- ) - })} -
- )} - {wrapperTables.length > 0 && ( -
- -
- )} - {wrapperTables.length === 0 && formErrors.tables && ( -

{formErrors.tables}

- )} - - -
- - - - - - ) - }} - +
+ +
+ {tablesField.length === 0 && errors.tables && ( +

+ {errors.tables.message?.toString()} +

+ )} + + + + + + + + +
{ 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) }} >

@@ -405,11 +390,10 @@ export const EditWrapperSheet = ({ { setSelectedTableToEdit(undefined) - setIsEditingTable(false) }} onSave={onUpdateTable} initialData={selectedTableToEdit} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx index b124fb097df..06c08cfec06 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx @@ -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 + loading?: boolean } -const InputField = ({ option, loading, error }: InputFieldProps) => { - const [showHidden, setShowHidden] = useState(!option.secureEntry) - if (option.isTextArea) { - return ( -

- { + return ( + ( +

{option.label}

{option.urlHelper !== undefined && ( + Documentation { )}
} - value={loading ? 'Fetching value from Vault...' : undefined} - defaultValue={option.defaultValue ?? ''} - required={option.required ?? false} - /> -
- ) - } else { - return ( - -

{option.label}

- {option.urlHelper !== undefined && ( - - - + labelOptional={!option.required ? 'Optional' : undefined} + description={option.description} + > + + {loading ? ( + + Fetching value from Vault... + + ) : option.isTextArea ? ( +