RoleRow.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { ChevronUp, MoreVertical, Trash } from 'lucide-react'
  3. import { useEffect, useState } from 'react'
  4. import { useForm, type SubmitHandler } from 'react-hook-form'
  5. import { toast } from 'sonner'
  6. import {
  7. Button,
  8. cn,
  9. Collapsible,
  10. CollapsibleContent,
  11. CollapsibleTrigger,
  12. DropdownMenu,
  13. DropdownMenuContent,
  14. DropdownMenuItem,
  15. DropdownMenuTrigger,
  16. Form,
  17. FormControl,
  18. FormField,
  19. Switch,
  20. } from 'ui'
  21. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  22. import * as z from 'zod'
  23. import { ROLE_PERMISSIONS } from './Roles.constants'
  24. import { useDatabaseRoleUpdateMutation } from '@/data/database-roles/database-role-update-mutation'
  25. import type { PgRole } from '@/data/database-roles/database-roles-query'
  26. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  27. interface RoleRowProps {
  28. role: PgRole
  29. disabled?: boolean
  30. onSelectDelete: (role: string) => void
  31. }
  32. const permissionSchema = z.boolean().optional()
  33. const formSchema = z.object(
  34. Object.keys(ROLE_PERMISSIONS).reduce(
  35. (acc, key) => ({
  36. ...acc,
  37. [key]: permissionSchema,
  38. }),
  39. {} as Record<keyof typeof ROLE_PERMISSIONS, z.ZodBoolean>
  40. )
  41. )
  42. export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
  43. const { data: project } = useSelectedProjectQuery()
  44. const [isExpanded, setIsExpanded] = useState(false)
  45. const { mutate: updateDatabaseRole, isPending: isUpdating } = useDatabaseRoleUpdateMutation()
  46. const form = useForm<z.infer<typeof formSchema>>({
  47. resolver: zodResolver(formSchema as any),
  48. defaultValues: role,
  49. })
  50. const { reset, formState } = form
  51. const { isDirty } = formState
  52. useEffect(() => {
  53. reset(role)
  54. }, [role, reset])
  55. const onSaveChanges: SubmitHandler<z.infer<typeof formSchema>> = async (values) => {
  56. if (!project) return console.error('Project is required')
  57. const changed = Object.fromEntries(
  58. Object.entries(values).filter(([k, v]) => {
  59. const key = k as keyof PgRole
  60. return v !== role[key]
  61. })
  62. )
  63. if (Object.keys(changed).length === 0) {
  64. // No actual changes to persist; avoid sending an empty update payload
  65. reset(role)
  66. return
  67. }
  68. updateDatabaseRole(
  69. {
  70. projectRef: project.ref,
  71. connectionString: project.connectionString,
  72. id: role.id,
  73. payload: changed,
  74. },
  75. {
  76. onSuccess: () => {
  77. toast.success(`Successfully updated role "${role.name}"`)
  78. reset(values)
  79. },
  80. }
  81. )
  82. }
  83. const formId = `role-update-form-${role.id}`
  84. return (
  85. <Collapsible
  86. open={isExpanded}
  87. className={cn(
  88. 'bg-surface-100',
  89. 'hover:bg-overlay-hover',
  90. 'data-open:bg-selection',
  91. 'border-default hover:border-strong',
  92. 'data-open:border-strong',
  93. 'data-open:pb-px col-span-12 mx-auto',
  94. '-space-y-px overflow-hidden',
  95. 'border border-t-0 first:border-t first:mt-0! hover:border-t hover:-mt-px shadow-sm transition hover:z-50',
  96. 'first:rounded-tl first:rounded-tr',
  97. 'last:rounded-bl last:rounded-br'
  98. )}
  99. >
  100. <div className={cn('flex items-center relative', !disabled && 'pr-(--card-padding-x)')}>
  101. <CollapsibleTrigger asChild>
  102. <button
  103. id={`collapsible-trigger-${role.id}`}
  104. type="button"
  105. className="group flex w-full items-center justify-between rounded-sm py-3 px-card text-foreground"
  106. onClick={(event) => {
  107. event.preventDefault()
  108. event.stopPropagation()
  109. setIsExpanded(!isExpanded)
  110. }}
  111. >
  112. <div className="flex items-start space-x-3">
  113. <ChevronUp
  114. className="text-border-stronger transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
  115. strokeWidth={2}
  116. width={14}
  117. />
  118. <div className="space-x-2 flex items-center">
  119. <p className="text-left text-sm">{role.name}</p>
  120. <p className="text-left text-sm text-foreground-light">(ID: {role.id})</p>
  121. </div>
  122. </div>
  123. <div className="flex items-center space-x-4">
  124. {role.activeConnections > 0 && (
  125. <div className="relative h-2 w-2">
  126. <span className="flex h-2 w-2">
  127. <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-75"></span>
  128. <span className="relative inline-flex h-2 w-2 rounded-full bg-brand opacity-75"></span>
  129. </span>
  130. </div>
  131. )}
  132. <p
  133. className={cn(
  134. `text-sm`,
  135. role.activeConnections > 0 ? 'text-foreground' : 'text-foreground-light'
  136. )}
  137. >
  138. {role.activeConnections} connections
  139. </p>
  140. </div>
  141. </button>
  142. </CollapsibleTrigger>
  143. {!disabled && (
  144. <DropdownMenu>
  145. <DropdownMenuTrigger asChild>
  146. <Button
  147. type="default"
  148. className="px-1"
  149. icon={<MoreVertical />}
  150. aria-label={`${role.name} actions`}
  151. />
  152. </DropdownMenuTrigger>
  153. <DropdownMenuContent side="bottom" align="end" className="w-[120px]">
  154. <DropdownMenuItem
  155. className="space-x-2"
  156. onClick={(event) => {
  157. event.stopPropagation()
  158. onSelectDelete(role.id.toString())
  159. }}
  160. >
  161. <Trash className="text-red-800" size="14" strokeWidth={2} />
  162. <p>Delete</p>
  163. </DropdownMenuItem>
  164. </DropdownMenuContent>
  165. </DropdownMenu>
  166. )}
  167. </div>
  168. <CollapsibleContent>
  169. <Form {...form}>
  170. <form
  171. id={formId}
  172. onSubmit={form.handleSubmit(onSaveChanges)}
  173. className="group border-t border-default bg-surface-100 py-6 px-5 md:px-20 text-foreground"
  174. >
  175. <div className="py-4 space-y-[9px]">
  176. {(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]).map(
  177. (permission) => (
  178. <FormField
  179. key={permission}
  180. control={form.control}
  181. name={permission}
  182. disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
  183. render={({ field }) => (
  184. <FormItemLayout
  185. id={`${role.id}-${permission}`}
  186. layout="flex"
  187. label={ROLE_PERMISSIONS[permission].description}
  188. >
  189. <FormControl>
  190. <Switch
  191. id={`${role.id}-${permission}`}
  192. checked={field.value}
  193. onCheckedChange={field.onChange}
  194. disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
  195. />
  196. </FormControl>
  197. </FormItemLayout>
  198. )}
  199. />
  200. )
  201. )}
  202. </div>
  203. {!disabled && (
  204. <div className="py-4 flex items-center space-x-2 justify-end">
  205. <Button type="default" disabled={!isDirty || isUpdating} onClick={() => reset()}>
  206. Cancel
  207. </Button>
  208. <Button
  209. type="primary"
  210. htmlType="submit"
  211. disabled={!isDirty || isUpdating}
  212. loading={isUpdating}
  213. >
  214. Save
  215. </Button>
  216. </div>
  217. )}
  218. </form>
  219. </Form>
  220. </CollapsibleContent>
  221. </Collapsible>
  222. )
  223. }