Files
supabase/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx
T
Gildas Garcia bfb0737d14 Fix to ensure labels, descriptions and validation errors are correctly linked to their inputs (#50080)
## Problem

`FormItemLayout` does not correctly binds inputs descriptions and
validation messages to their inputs. This is because the input ids are
generated and not correctly propagated to the `FormMessage` and
`FormDescription` components. Besides, we still pass `name` or `id`
directly to the inputs or `FormItemLayout` in some places.

## Solution

- Fix `FormItemLayout` to correctly binds inputs descriptions and
validation messages to their inputs
- Fix incorrect usages
- Fix Design System documentation

## How to test

The issue is visible in production:
- Open https://supabase.com/design-system/docs/ui-patterns/forms
- Open the devtool and check the labels `for`, the description `id` and
the input `id` or `aria-describedby` attributes. You'll see they often
don't match

Do the same on staging:
- Open
https://design-system-git-fix-a11y-form-input-descriptions-supabase.vercel.app/design-system/docs/ui-patterns/forms
- Open the devtool and check the labels `for`, the description `id` and
the input `id` or `aria-describedby` attributes. They now match

Dashboard fixes:
-
https://studio-staging-git-fix-a11y-form-input-descriptions-supabase.vercel.app/dashboard/account/tokens:
_Expires in_ select button is now correctly linked to its label
-
https://studio-staging-git-fix-a11y-form-input-descriptions-supabase.vercel.app/dashboard/account/me:
the switches are now correctly linked to their label
- In Database/Indexes: the select buttons when creating an index are now
correctly linked to their label
- All other changes are the same things
2026-09-08 09:47:32 +02:00

378 lines
12 KiB
TypeScript

import { format } from 'date-fns'
import { CalendarIcon, ExternalLink } from 'lucide-react'
import { useEffect } from 'react'
import { useFormContext, type Control } from 'react-hook-form'
import ReactMarkdown from 'react-markdown'
import {
Button,
Calendar,
FormControl,
FormInputGroupInput,
Input,
InputGroup,
InputGroupAddon,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
SheetSection,
Switch,
Textarea,
FormField as UIFormField,
useWatch,
} from 'ui'
import { Input as DataInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import type { Enum } from './AuthProvidersForm.types'
import { Markdown } from '@/components/interfaces/Markdown'
import { BASE_PATH } from '@/lib/constants'
interface FormFieldProps {
projectRef: string | undefined
organizationSlug: string | undefined
name: string
properties: any
control: Control
hasAccess: boolean
disabled?: boolean
readOnly?: boolean
}
const FormField = ({
projectRef,
name,
properties,
organizationSlug,
control,
hasAccess,
disabled: disabledProp,
readOnly,
}: FormFieldProps) => {
const { setValue } = useFormContext()
const { description: originalDescription } = properties
let description = originalDescription
if (originalDescription && projectRef) {
description = originalDescription.replace(
/\(\.\.\/auth\/(.*?)\)/g,
`(/project/${projectRef}/auth/$1)`
)
}
const fieldValue = useWatch({ control, name })
if (!hasAccess) {
const planMessage = organizationSlug
? `Only available on [Pro plan](/org/${organizationSlug}/billing?panel=subscriptionPlan) and above.`
: ''
description = originalDescription ? `${originalDescription} ${planMessage}` : planMessage
}
const disabled =
disabledProp || (properties.type === 'boolean' ? !hasAccess && !fieldValue : !hasAccess)
const showValue = useWatch({
control,
name: properties.show?.key,
disabled: properties.show == null,
})
useEffect(() => {
if (properties.show?.key != null && !showValue && fieldValue !== '') {
setValue(name, '', { shouldDirty: true })
}
}, [fieldValue, name, properties.show?.key, setValue, showValue])
if (properties.show) {
if (properties.show.matches) {
if (!properties.show.matches.includes(showValue)) {
return null
}
} else if (!showValue) {
return null
}
}
switch (properties.type) {
case 'datetime':
return (
<>
<SheetSection>
<UIFormField
control={control}
name={name}
disabled={disabled || readOnly}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={properties.title}
description={
description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{description}
</ReactMarkdown>
) : null
}
>
<Popover>
<FormControl>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal px-3 py-4"
icon={<CalendarIcon className="h-4 w-4" />}
size="small"
>
{field.value ? format(new Date(field.value), 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
</FormControl>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date?.toISOString())
}}
initialFocus
/>
</PopoverContent>
</Popover>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
</>
)
case 'string':
return (
<>
<SheetSection>
<UIFormField
control={control}
name={name}
disabled={disabled}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={properties.title}
description={
description ? (
<Markdown content={description} className="text-foreground-lighter" />
) : null
}
>
<FormControl className="col-span-6">
{properties.isSecret ? (
<DataInput {...field} size="small" copy reveal readOnly={readOnly} />
) : (
<Input {...field} readOnly={readOnly} />
)}
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
</>
)
case 'multiline-string':
return (
<>
<SheetSection>
<UIFormField
control={control}
name={name}
disabled={disabled}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={properties.title}
description={
description ? (
<Markdown content={description} className="text-foreground-lighter" />
) : null
}
>
<FormControl className="col-span-6">
<Textarea
{...field}
rows={4}
placeholder="Enter multi-line text"
className="resize-none"
readOnly={readOnly}
/>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
</>
)
case 'number':
return (
<>
<SheetSection>
<UIFormField
control={control}
name={name}
disabled={disabled}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={properties.title}
description={
description ? (
<Markdown content={description} className="text-foreground-lighter" />
) : null
}
>
{properties.units ? (
<FormControl className="col-span-6">
<InputGroup>
<FormInputGroupInput
{...field}
type="number"
onChange={(e) =>
field.onChange(e.target.value === '' ? '' : Number(e.target.value))
}
readOnly={readOnly}
/>
<InputGroupAddon align="inline-end">
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{properties.units}
</ReactMarkdown>
</InputGroupAddon>
</InputGroup>
</FormControl>
) : (
<FormControl className="col-span-6">
<Input
{...field}
type="number"
onChange={(e) =>
field.onChange(e.target.value === '' ? '' : Number(e.target.value))
}
readOnly={readOnly}
/>
</FormControl>
)}
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
</>
)
case 'boolean':
return (
<>
<SheetSection>
<UIFormField
control={control}
name={name}
disabled={disabled || readOnly}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={properties.title}
description={
<div className="flex flex-col gap-1">
{description ? <Markdown content={description} /> : null}
{properties.link && (
<span>
<Button asChild variant="default" size="tiny" icon={<ExternalLink />}>
<a href={properties.link} target="_blank" rel="noreferrer noopener">
Documentation
</a>
</Button>
</span>
)}
</div>
}
>
<FormControl className="col-span-6">
<Switch checked={field.value} onCheckedChange={field.onChange} size="small" />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
</>
)
case 'select':
return (
<>
<SheetSection>
<UIFormField
control={control}
name={name}
disabled={disabled || readOnly}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={properties.title}
description={
description ? (
<div className="form-field-markdown">
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{description}
</ReactMarkdown>
</div>
) : null
}
>
<Select
defaultValue={properties.enum[0]?.value}
value={field.value}
onValueChange={field.onChange}
>
<FormControl className="col-span-6">
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
</FormControl>
<SelectContent>
{properties.enum.map((option: Enum) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex gap-2 items-center">
{option.icon ? (
<img
alt={`${option.label} icon`}
className="h-6 w-6"
src={`${BASE_PATH}/img/icons/${option.icon}`}
/>
) : null}
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
</>
)
default:
return null
}
}
export default FormField