diff --git a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx index 71f5143ba9a..046fa019bb4 100644 --- a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx @@ -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 + ) +) + 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>({ + resolver: zodResolver(formSchema), + defaultValues: role, + }) - const onSaveChanges = async (values: Partial, { resetForm }: any) => { + const { reset, formState } = form + const { isDirty } = formState + + useEffect(() => { + reset(role) + }, [role, reset]) + + const onSaveChanges: SubmitHandler> = 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 ( -
- {({ values, initialValues, handleReset }: any) => { - const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) - - return ( - - - + + {!disabled && ( + + + - - -
-
- {(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]).map( - (permission) => ( - +

Delete

+ + + + )} +
+ + + +
+ {(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]).map( + (permission) => ( + ( + div>button]:opacity-30 [&>div>label]:text-foreground-lighter' - : '', - ].join(' ')} - afterLabel={ - !disabled && - ROLE_PERMISSIONS[permission].disabled && ( - - - - - - This privilege cannot be updated via the dashboard - - - ) - } - /> - ) - )} -
- {!disabled && ( -
- - -
- )} + > + + + + + )} + /> + ) + )} +
+ {!disabled && ( +
+ +
-
-
- ) - }} -
+ )} + + + + ) } diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index d8dfcf2085a..359b184a613 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -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()