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