RenameQueryModal.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { useParams } from 'common'
  3. import { useRouter } from 'next/router'
  4. import { useEffect } from 'react'
  5. import { SubmitHandler, useForm } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import { AiIconAnimation, Button, Form, FormControl, FormField, Input, Modal, Textarea } from 'ui'
  8. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  9. import * as z from 'zod'
  10. import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
  11. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  12. import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query'
  13. import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
  14. import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
  15. import { getContentById } from '@/data/content/content-id-query'
  16. import {
  17. UpsertContentPayload,
  18. useContentUpsertMutation,
  19. } from '@/data/content/content-upsert-mutation'
  20. import { Snippet } from '@/data/content/sql-folders-query'
  21. import type { SqlSnippet } from '@/data/content/sql-snippets-query'
  22. import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
  23. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  24. import { IS_PLATFORM } from '@/lib/constants'
  25. import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
  26. import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
  27. export interface RenameQueryModalProps {
  28. snippet?: SqlSnippet | Snippet
  29. visible: boolean
  30. onCancel: () => void
  31. onComplete: () => void
  32. }
  33. const formSchema = z.object({
  34. name: z.string().min(1, 'Please enter a query name'),
  35. description: z.string().optional(),
  36. })
  37. const RenameQueryModal = ({
  38. snippet = {} as any,
  39. visible,
  40. onCancel,
  41. onComplete,
  42. }: RenameQueryModalProps) => {
  43. const { ref } = useParams()
  44. const router = useRouter()
  45. const { data: organization } = useSelectedOrganizationQuery()
  46. const snapV2 = useSqlEditorV2StateSnapshot()
  47. const tabsSnap = useTabsStateSnapshot()
  48. const { data: subscription } = useOrgSubscriptionQuery(
  49. { orgSlug: organization?.slug },
  50. { enabled: visible }
  51. )
  52. const isSQLSnippet = snippet.type === 'sql'
  53. const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref })
  54. // Customers on HIPAA plans should not have access to Briven AI
  55. const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive
  56. const { id, name, description } = snippet
  57. const { mutate: getGeneratedValues, isPending: isTitleGenerationLoading } =
  58. useSqlTitleGenerateMutation({
  59. onSuccess: (data) => {
  60. const { title, description } = data
  61. form.setValue('name', title, { shouldDirty: true })
  62. if (!form.getValues().description) {
  63. form.setValue('description', description, { shouldDirty: true })
  64. }
  65. },
  66. onError: (error) => {
  67. toast.error(`Failed to generate title and description: ${error.message}`)
  68. },
  69. })
  70. const { data: check } = useCheckOpenAIKeyQuery()
  71. const isApiKeySet = !!check?.hasKey
  72. const generateTitle = async () => {
  73. if ('content' in snippet && isSQLSnippet) {
  74. getGeneratedValues({ sql: snippet.content.unchecked_sql })
  75. } else {
  76. try {
  77. const { content } = await getContentById({ projectRef: ref, id: snippet.id })
  78. if ('unchecked_sql' in content) getGeneratedValues({ sql: content.unchecked_sql })
  79. } catch (error) {
  80. toast.error('Unable to generate title based on query contents')
  81. }
  82. }
  83. }
  84. const { mutateAsync: upsertContent } = useContentUpsertMutation()
  85. const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async ({ name, description }) => {
  86. if (!ref) return console.error('Project ref is required')
  87. if (!id) return console.error('Snippet ID is required')
  88. try {
  89. let localSnippet = snippet
  90. // [Joshen] For SQL V2 - content is loaded on demand so we need to fetch the data if its not already loaded in the valtio state
  91. if (!('content' in localSnippet)) {
  92. localSnippet = await getContentById({ projectRef: ref, id })
  93. snapV2.addSnippet({ projectRef: ref, snippet: localSnippet })
  94. }
  95. const changedSnippet = await upsertContent({
  96. projectRef: ref,
  97. payload: {
  98. ...localSnippet,
  99. name,
  100. description,
  101. } as UpsertContentPayload,
  102. })
  103. if (IS_PLATFORM) {
  104. snapV2.renameSnippet({ id, name, description })
  105. const tabId = createTabId('sql', { id })
  106. tabsSnap.updateTab(tabId, { label: name })
  107. } else if (changedSnippet) {
  108. // In self-hosted, the snippet also updates the id when renaming it. This code is to ensure the previous snippet
  109. // is removed, new one is added, tab state is updated and the router is updated.
  110. // remove the old snippet from the state without saving to API
  111. snapV2.removeSnippet(id, true)
  112. snapV2.addSnippet({ projectRef: ref, snippet: changedSnippet })
  113. // remove the tab for the old snippet if the snippet was open. Renaming can also happen when the tab is not open.
  114. const tabId = createTabId('sql', { id })
  115. if (tabsSnap.hasTab(tabId)) {
  116. tabsSnap.removeTab(tabId)
  117. await router.push(`/project/${ref}/sql/${changedSnippet.id}`)
  118. }
  119. }
  120. toast.success('Successfully renamed snippet!')
  121. if (onComplete) onComplete()
  122. } catch (error: any) {
  123. // [Joshen] We probably need some rollback cause all the saving is async
  124. toast.error(`Failed to rename snippet: ${error.message}`)
  125. }
  126. }
  127. const form = useForm<z.infer<typeof formSchema>>({
  128. resolver: zodResolver(formSchema as any),
  129. defaultValues: { name: name ?? '', description: description ?? '' },
  130. })
  131. const { reset, formState } = form
  132. const { isDirty, isSubmitting } = formState
  133. useEffect(() => {
  134. if (isDirty) return
  135. reset({ name: name ?? '', description: description ?? '' })
  136. }, [id, name, description, reset, isDirty])
  137. const handleCancel = () => {
  138. onCancel()
  139. reset()
  140. }
  141. return (
  142. <Modal visible={visible} onCancel={handleCancel} hideFooter header="Rename" size="small">
  143. <Form {...form}>
  144. <form onSubmit={form.handleSubmit(onSubmit)} noValidate>
  145. <Modal.Content className="space-y-4">
  146. <FormField
  147. control={form.control}
  148. name="name"
  149. render={({ field }) => (
  150. <FormItemLayout name="name" layout="vertical" label="Name">
  151. <FormControl>
  152. <Input {...field} id="name" />
  153. </FormControl>
  154. </FormItemLayout>
  155. )}
  156. />
  157. <div className="flex w-full justify-end mt-2">
  158. {!hasHipaaAddon && (
  159. <ButtonTooltip
  160. type="default"
  161. onClick={() => generateTitle()}
  162. size="tiny"
  163. disabled={isTitleGenerationLoading || !isApiKeySet}
  164. tooltip={{
  165. content: {
  166. side: 'bottom',
  167. text: isApiKeySet
  168. ? undefined
  169. : 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.',
  170. },
  171. }}
  172. >
  173. <div className="flex items-center gap-1">
  174. <div className="scale-75">
  175. <AiIconAnimation loading={isTitleGenerationLoading} />
  176. </div>
  177. <span>Rename with Briven AI</span>
  178. </div>
  179. </ButtonTooltip>
  180. )}
  181. </div>
  182. </Modal.Content>
  183. <Modal.Content>
  184. <FormField
  185. control={form.control}
  186. name="description"
  187. render={({ field }) => (
  188. <FormItemLayout name="description" layout="vertical" label="Description">
  189. <FormControl>
  190. <Textarea
  191. {...field}
  192. id="description"
  193. rows={4}
  194. placeholder="Describe query"
  195. className="resize-none"
  196. />
  197. </FormControl>
  198. </FormItemLayout>
  199. )}
  200. />
  201. </Modal.Content>
  202. <Modal.Separator />
  203. <Modal.Content className="flex items-center justify-end gap-2">
  204. <Button htmlType="reset" type="default" onClick={handleCancel} disabled={isSubmitting}>
  205. Cancel
  206. </Button>
  207. <Button htmlType="submit" loading={isSubmitting} disabled={isSubmitting || !isDirty}>
  208. Rename query
  209. </Button>
  210. </Modal.Content>
  211. </form>
  212. </Form>
  213. </Modal>
  214. )
  215. }
  216. export default RenameQueryModal