mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 22:18:00 +08:00
chore: update database role form to use react-hook-form (#44178)
## Problem - The database role form still uses `formik` and we want to remove it in favour of `react-hook-form` to keep only one form library - The database role form does not follow the design system guidelines ## Solution - Migrate to `react-hook-form` - Apply the design system guidelines - Make sure you can toggle switches by clicking their labels too - Fix accessibility issues ## Notes In the new design system, labels for disabled inputs have the same style as those for enabled inputs ## Screenshots Before: <img width="1325" height="288" alt="image" src="https://github.com/user-attachments/assets/5e558618-3227-42be-b085-4ee388c0aff6" /> <img width="1328" height="402" alt="image" src="https://github.com/user-attachments/assets/9e41a4c2-ab38-4772-b619-548a1a0b9556" /> After: <img width="1281" height="325" alt="image" src="https://github.com/user-attachments/assets/698e526c-5ae3-4e89-bcb5-0bfee1b70f72" /> <img width="1285" height="428" alt="image" src="https://github.com/user-attachments/assets/65b30dc2-9724-4609-9fd0-f32171e37abd" />
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useDatabaseRoleUpdateMutation } from 'data/database-roles/database-role-update-mutation'
|
||||
import { PgRole } from 'data/database-roles/database-roles-query'
|
||||
import type { PgRole } from 'data/database-roles/database-roles-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { ChevronUp, HelpCircle, MoreVertical, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronUp, MoreVertical, Trash } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, type SubmitHandler } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
@@ -12,12 +14,13 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
Toggle,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { ROLE_PERMISSIONS } from './Roles.constants'
|
||||
|
||||
@@ -27,22 +30,50 @@ interface RoleRowProps {
|
||||
onSelectDelete: (role: string) => void
|
||||
}
|
||||
|
||||
const permissionSchema = z.boolean().optional()
|
||||
const formSchema = z.object(
|
||||
Object.keys(ROLE_PERMISSIONS).reduce(
|
||||
(acc, key) => ({
|
||||
...acc,
|
||||
[key]: permissionSchema,
|
||||
}),
|
||||
{} as Record<keyof typeof ROLE_PERMISSIONS, z.ZodBoolean>
|
||||
)
|
||||
)
|
||||
|
||||
export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const { mutate: updateDatabaseRole, isPending: isUpdating } = useDatabaseRoleUpdateMutation()
|
||||
|
||||
const { isSuperuser, canLogin, canCreateRole, canCreateDb, isReplicationRole, canBypassRls } =
|
||||
role
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: role,
|
||||
})
|
||||
|
||||
const onSaveChanges = async (values: Partial<PgRole>, { resetForm }: any) => {
|
||||
const { reset, formState } = form
|
||||
const { isDirty } = formState
|
||||
|
||||
useEffect(() => {
|
||||
reset(role)
|
||||
}, [role, reset])
|
||||
|
||||
const onSaveChanges: SubmitHandler<z.infer<typeof formSchema>> = async (values) => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
const changed = Object.fromEntries(
|
||||
Object.entries(values).filter(([k, v]) => v !== (role as any)[k])
|
||||
Object.entries(values).filter(([k, v]) => {
|
||||
const key = k as keyof PgRole
|
||||
return v !== role[key]
|
||||
})
|
||||
)
|
||||
|
||||
if (Object.keys(changed).length === 0) {
|
||||
// No actual changes to persist; avoid sending an empty update payload
|
||||
reset(role)
|
||||
return
|
||||
}
|
||||
|
||||
updateDatabaseRole(
|
||||
{
|
||||
projectRef: project.ref,
|
||||
@@ -53,24 +84,17 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully updated role "${role.name}"`)
|
||||
resetForm({ values: { ...values }, initialValues: { ...values } })
|
||||
reset(values)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const formId = `role-update-form-${role.id}`
|
||||
|
||||
return (
|
||||
<Form
|
||||
name="role-update-form"
|
||||
initialValues={{
|
||||
isSuperuser,
|
||||
canLogin,
|
||||
canCreateRole,
|
||||
canCreateDb,
|
||||
isReplicationRole,
|
||||
canBypassRls,
|
||||
}}
|
||||
onSubmit={onSaveChanges}
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
className={cn(
|
||||
'bg-surface-100',
|
||||
'hover:bg-overlay-hover',
|
||||
@@ -84,138 +108,127 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
'last:rounded-bl last:rounded-br'
|
||||
)}
|
||||
>
|
||||
{({ values, initialValues, handleReset }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
id="collapsible-trigger"
|
||||
type="button"
|
||||
className="group flex w-full items-center justify-between rounded py-3 px-card text-foreground"
|
||||
onClick={(event: any) => {
|
||||
if (event.target.id === 'collapsible-trigger') setIsExpanded(!isExpanded)
|
||||
<div className={cn('flex items-center relative', !disabled && 'pr-[--card-padding-x]')}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
id={`collapsible-trigger-${role.id}`}
|
||||
type="button"
|
||||
className="group flex w-full items-center justify-between rounded py-3 px-card text-foreground"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsExpanded(!isExpanded)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<ChevronUp
|
||||
className="text-border-stronger transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
|
||||
strokeWidth={2}
|
||||
width={14}
|
||||
/>
|
||||
<div className="space-x-2 flex items-center">
|
||||
<p className="text-left text-sm">{role.name}</p>
|
||||
<p className="text-left text-sm text-foreground-light">(ID: {role.id})</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{role.activeConnections > 0 && (
|
||||
<div className="relative h-2 w-2">
|
||||
<span className="flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand opacity-75"></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
`text-sm`,
|
||||
role.activeConnections > 0 ? 'text-foreground' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{role.activeConnections} connections
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
{!disabled && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<MoreVertical />}
|
||||
aria-label={`${role.name} actions`}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-[120px]">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSelectDelete(role.id.toString())
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<ChevronUp
|
||||
id="collapsible-trigger"
|
||||
className="text-border-stronger transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
|
||||
strokeWidth={2}
|
||||
width={14}
|
||||
/>
|
||||
<div className="space-x-2 flex items-center">
|
||||
<p className="text-left text-sm" id="collapsible-trigger">
|
||||
{role.name}
|
||||
</p>
|
||||
<p className="text-left text-sm text-foreground-light" id="collapsible-trigger">
|
||||
(ID: {role.id})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{role.activeConnections > 0 && (
|
||||
<div className="relative h-2 w-2">
|
||||
<span className="flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand opacity-75"></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
id="collapsible-trigger"
|
||||
className={`text-sm ${
|
||||
role.activeConnections > 0 ? 'text-foreground' : 'text-foreground-light'
|
||||
}`}
|
||||
>
|
||||
{role.activeConnections} connections
|
||||
</p>
|
||||
{!disabled && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" className="w-[120px]">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSelectDelete(role.id.toString())
|
||||
}}
|
||||
>
|
||||
<Trash className="text-red-800" size="14" strokeWidth={2} />
|
||||
<p>Delete</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div className="group border-t border-default bg-surface-100 py-6 px-5 md:px-20 text-foreground">
|
||||
<div className="py-4 space-y-[9px]">
|
||||
{(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]).map(
|
||||
(permission) => (
|
||||
<Toggle
|
||||
size="small"
|
||||
key={permission}
|
||||
id={permission}
|
||||
name={permission}
|
||||
<Trash className="text-red-800" size="14" strokeWidth={2} />
|
||||
<p>Delete</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(onSaveChanges)}
|
||||
className="group border-t border-default bg-surface-100 py-6 px-5 md:px-20 text-foreground"
|
||||
>
|
||||
<div className="py-4 space-y-[9px]">
|
||||
{(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]).map(
|
||||
(permission) => (
|
||||
<FormField_Shadcn_
|
||||
key={permission}
|
||||
control={form.control}
|
||||
name={permission}
|
||||
disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
id={`${role.id}-${permission}`}
|
||||
layout="flex"
|
||||
label={ROLE_PERMISSIONS[permission].description}
|
||||
disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
|
||||
className={[
|
||||
'roles-toggle',
|
||||
disabled || ROLE_PERMISSIONS[permission].disabled
|
||||
? '[&>div>button]:opacity-30 [&>div>label]:text-foreground-lighter'
|
||||
: '',
|
||||
].join(' ')}
|
||||
afterLabel={
|
||||
!disabled &&
|
||||
ROLE_PERMISSIONS[permission].disabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle
|
||||
size="14"
|
||||
strokeWidth={2}
|
||||
className="ml-2 relative top-[3px]"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
This privilege cannot be updated via the dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="py-4 flex items-center space-x-2 justify-end">
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!hasChanges || isUpdating}
|
||||
onClick={() => handleReset()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!hasChanges || isUpdating}
|
||||
loading={isUpdating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
id={`${role.id}-${permission}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="py-4 flex items-center space-x-2 justify-end">
|
||||
<Button type="default" disabled={!isDirty || isUpdating} onClick={() => reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!isDirty || isUpdating}
|
||||
loading={isUpdating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -723,7 +723,7 @@ test.describe('Database', () => {
|
||||
// delete role if exists
|
||||
const exists = (await page.getByRole('button', { name: databaseRoleName }).count()) > 0
|
||||
if (exists) {
|
||||
await page.getByRole('button', { name: databaseRoleName }).getByRole('button').click()
|
||||
await page.getByRole('button', { name: `${databaseRoleName} actions` }).click()
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await expect(
|
||||
@@ -745,7 +745,7 @@ test.describe('Database', () => {
|
||||
).toBeVisible({ timeout: 50000 })
|
||||
|
||||
// delete a role
|
||||
await page.getByRole('button', { name: databaseRoleName }).getByRole('button').click()
|
||||
await page.getByRole('button', { name: `${databaseRoleName} actions` }).click()
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
const roleDeleteWait = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=roles-delete')
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
|
||||
Reference in New Issue
Block a user