Fix wrapper forms design (#46283)

## Screenshots

Before:
<img width="983" height="959" alt="image"
src="https://github.com/user-attachments/assets/b084ce4f-9ec8-4096-af02-b997d712edbb"
/>
<img width="472" height="962" alt="image"
src="https://github.com/user-attachments/assets/2ba5ccfd-71cc-4375-b87b-f74af64cec20"
/>

After:
<img width="1822" height="2632" alt="image"
src="https://github.com/user-attachments/assets/fa70bdd2-63c5-4259-aad5-d060f498a494"
/>
<img width="900" height="1120" alt="image"
src="https://github.com/user-attachments/assets/1d267828-c260-4220-9493-a28f012898f9"
/>


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

* **Style**
* Clearer section separation using visual separators for wrapper
creation/edit flows.
  * Adjusted column selector sizing for improved responsiveness.
  * Secure fields show a loading state while retrieving secrets.

* **Refactor**
* Unified form section layout across wrapper management for more
consistent, scannable configuration panels.
* Simplified data-target and schema areas for easier navigation and
editing.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46283?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-05-25 11:23:35 +02:00
1 parent fdd6131567
commit d4274ea27e
4 files changed
+338 -406

No files matched your search

@@ -4,8 +4,6 @@ import { SubmitHandler, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
Form,
FormControl,
FormField,
@@ -18,18 +16,15 @@ import {
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 {
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'
@@ -258,173 +253,144 @@ export const CreateIcebergWrapperSheet = ({
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<SheetSection className="grow overflow-y-auto">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField
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>
<Input {...field} />
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Data target</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField
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>
</FormItemLayout>
)}
/>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<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>
)
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
<FormField
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>
<Input {...field} />
</FormControl>
</FormItemLayout>
)}
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Schema</PageSectionTitle>
<PageSectionDescription>
/>
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Data target</FormSectionLabel>}>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
<FormField
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>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
{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)}
/>
) : (
<InputField
key={`${option.name}-${option.required}-${option.hidden}`}
control={form.control}
option={option}
/>
)
)}
</FormSectionContent>
</FormSection>
<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.
</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>
</p>
</FormSectionLabel>
}
>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
{wrapperMeta.sourceSchemaOption && (
<InputField control={form.control} option={wrapperMeta.sourceSchemaOption} />
)}
<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,
}}
/>
</FormSectionContent>
</FormSection>
</SheetSection>
<SheetFooter>
@@ -6,35 +6,30 @@ import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
Form,
FormControl,
FormField,
Input,
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 { 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'
@@ -221,70 +216,51 @@ export const CreateWrapperSheet = ({
<SheetHeader>
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<SheetSection className="grow overflow-y-auto">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField
control={form.control}
<div className="flex-grow overflow-y-auto">
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
<FormField
control={form.control}
name="wrapper_name"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Wrapper Name"
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>
<Input id="wrapper_name" {...field} />
</FormControl>
</FormItemLayout>
)}
/>
</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>
description={
wrapper_name.length > 0 ? (
<>
Your wrapper's server name will be{' '}
<code className="text-code-inline">{wrapper_name}_server</code>
</>
) : (
''
)
}
>
<FormControl>
<Input id="wrapper_name" {...field} />
</FormControl>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<Separator />
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField option={option} control={form.control} key={option.name} />
))}
</FormSectionContent>
</FormSection>
<Separator />
<FormSection header={<FormSectionLabel>Data target</FormSectionLabel>}>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
<FormField
control={form.control}
name="mode"
@@ -360,84 +336,90 @@ export const CreateWrapperSheet = ({
</FormItemLayout>
)}
/>
</PageSectionContent>
</PageSection>
</FormSectionContent>
</FormSection>
<Separator />
{mode === 'tables' && (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Tables</PageSectionTitle>
<PageSectionDescription>
<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
</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="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>
)
})}
<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>
</FormSectionLabel>
}
>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
<div 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="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>
)
})}
<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>
)}
</div>
</FormSectionContent>
</FormSection>
)}
<Separator />
{mode === 'schema' && (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Schema</PageSectionTitle>
<PageSectionDescription>
<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.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
</p>
</FormSectionLabel>
}
>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
{wrapperMeta.sourceSchemaOption &&
!wrapperMeta.sourceSchemaOption?.readOnly && (
// Hide the field if the source schema is read-only
@@ -462,10 +444,10 @@ export const CreateWrapperSheet = ({
control={form.control}
/>
</div>
</PageSectionContent>
</PageSection>
</FormSectionContent>
</FormSection>
)}
</SheetSection>
</div>
<SheetFooter>
<Button
size="tiny"
@@ -7,27 +7,17 @@ import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
Form,
FormControl,
FormField,
Input,
Separator,
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'
@@ -41,6 +31,11 @@ import {
} 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'
@@ -205,79 +200,68 @@ export const EditWrapperSheet = ({
Edit {wrapperMeta.label} wrapper: {wrapper.name}
</SheetTitle>
</SheetHeader>
<SheetSection className="grow overflow-y-auto">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormField
<div className="grow overflow-y-auto">
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
<FormField
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>
<Input {...field} />
</FormControl>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<Separator />
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
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>
<Input {...field} />
</FormControl>
</FormItemLayout>
)}
loading={option.secureEntry ? isLoadingSecrets : undefined}
/>
</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}
loading={option.secureEntry ? isLoadingSecrets : undefined}
/>
</CardContent>
))}
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Foreign Tables</PageSectionTitle>
<PageSectionDescription>
))}
</FormSectionContent>
</FormSection>
<Separator />
<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
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent className="flex flex-col space-y-2">
</p>
</FormSectionLabel>
}
>
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
{tablesField.map((t, tableIndex) => {
// FIXME: make inference work
const table = t as unknown as FormattedWrapperTable
@@ -327,9 +311,9 @@ export const EditWrapperSheet = ({
{errors.tables.message?.toString()}
</p>
)}
</PageSectionContent>
</PageSection>
</SheetSection>
</FormSectionContent>
</FormSection>
</div>
<SheetFooter>
<Button
size="tiny"
@@ -443,7 +443,7 @@ const TableForm = ({
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full min-w-lg!"
className="w-full"
/>
<MultiSelectorContent>
<MultiSelectorList>