CreateAnalyticsBucketForm.tsx281 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { useParams } from 'common'
4import { SubmitHandler, useForm } from 'react-hook-form'
5import { toast } from 'sonner'
6import {
7 Button,
8 cn,
9 DialogFooter,
10 DialogSection,
11 Form,
12 FormControl,
13 FormField,
14 Input,
15 SheetFooter,
16 SheetSection,
17} from 'ui'
18import { Admonition } from 'ui-patterns'
19import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
20import z from 'zod'
21
22import { useIcebergWrapperExtension } from './AnalyticsBucketDetails/useIcebergWrapper'
23import {
24 reservedPrefixes,
25 reservedSuffixes,
26 validBucketNameRegex,
27} from './CreateAnalyticsBucketForm.utils'
28import { InlineLink } from '@/components/ui/InlineLink'
29import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation'
30import { useAnalyticsBucketCreateMutation } from '@/data/storage/analytics-bucket-create-mutation'
31import { useAnalyticsBucketsQuery } from '@/data/storage/analytics-buckets-query'
32import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation'
33import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
34import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
35import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
36import { DOCS_URL } from '@/lib/constants'
37
38const FormSchema = z
39 .object({
40 name: z
41 .string()
42 .trim()
43 .min(3, 'Bucket name should be at least 3 characters')
44 .max(63, 'Bucket name should be up to 63 characters')
45 .refine(
46 (value) => !value.endsWith(' '),
47 'The name of the bucket cannot end with a whitespace'
48 )
49 .refine(
50 (value) => value !== 'public',
51 '"public" is a reserved name. Please choose another name'
52 ),
53 })
54 .superRefine((data, ctx) => {
55 if (reservedPrefixes.test(data.name)) {
56 const [match] = data.name.match(reservedPrefixes) ?? []
57 return ctx.addIssue({
58 path: ['name'],
59 code: z.ZodIssueCode.custom,
60 message: `Bucket name cannot start with "${match}"`,
61 })
62 }
63
64 if (reservedSuffixes.test(data.name)) {
65 const [match] = data.name.match(reservedSuffixes) ?? []
66 return ctx.addIssue({
67 path: ['name'],
68 code: z.ZodIssueCode.custom,
69 message: `Bucket name cannot end with "${match}"`,
70 })
71 }
72
73 if (/[A-Z]/.test(data.name)) {
74 return ctx.addIssue({
75 path: ['name'],
76 code: z.ZodIssueCode.custom,
77 message: 'Bucket name can only be lowercase characters',
78 })
79 }
80
81 if (!validBucketNameRegex.test(data.name)) {
82 if (!/^[a-z0-9]/.test(data.name)) {
83 return ctx.addIssue({
84 path: ['name'],
85 code: z.ZodIssueCode.custom,
86 message: 'Bucket name must start with a lowercase letter or number.',
87 })
88 }
89
90 if (!/[a-z0-9]$/.test(data.name)) {
91 return ctx.addIssue({
92 path: ['name'],
93 code: z.ZodIssueCode.custom,
94 message: 'Bucket name must end with a lowercase letter or number.',
95 })
96 }
97
98 const [match] = data.name.match(/[^a-z0-9-]/) ?? []
99 return ctx.addIssue({
100 path: ['name'],
101 code: z.ZodIssueCode.custom,
102 message: !!match
103 ? `Bucket name cannot contain the "${match}" character`
104 : 'Bucket name contains an invalid special character',
105 })
106 }
107 })
108
109const formId = 'create-analytics-storage-bucket-form'
110
111export type CreateAnalyticsBucketForm = z.infer<typeof FormSchema>
112
113interface CreateAnalyticsBucketFormProps {
114 type?: 'dialog' | 'sheet'
115 onOpenChange: (value: boolean) => void
116}
117
118export const CreateAnalyticsBucketForm = ({
119 type = 'dialog',
120 onOpenChange,
121}: CreateAnalyticsBucketFormProps) => {
122 const { ref } = useParams()
123 const { data: org } = useSelectedOrganizationQuery()
124 const { data: project } = useSelectedProjectQuery()
125 const { extension: wrappersExtension, state: wrappersExtensionState } =
126 useIcebergWrapperExtension()
127
128 const { data: buckets = [] } = useAnalyticsBucketsQuery({ projectRef: ref })
129 const wrappersExtensionNeedsUpgrading = wrappersExtensionState === 'needs-upgrade'
130
131 const { mutate: sendEvent } = useSendEventMutation()
132
133 const { mutateAsync: createAnalyticsBucket, isPending: isCreatingAnalyticsBucket } =
134 useAnalyticsBucketCreateMutation({
135 // [Joshen] Silencing the error here as it's being handled in onSubmit
136 onError: () => {},
137 })
138
139 const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } =
140 useIcebergWrapperCreateMutation()
141
142 const { mutateAsync: enableExtension, isPending: isEnablingExtension } =
143 useDatabaseExtensionEnableMutation()
144
145 const isCreating = isEnablingExtension || isCreatingIcebergWrapper || isCreatingAnalyticsBucket
146
147 const form = useForm<CreateAnalyticsBucketForm>({
148 resolver: zodResolver(FormSchema as any),
149 defaultValues: { name: '' },
150 })
151
152 const onSubmit: SubmitHandler<CreateAnalyticsBucketForm> = async (values) => {
153 if (!ref) return console.error('Project ref is required')
154 if (!project) return console.error('Project details is required')
155 if (!wrappersExtension) return console.error('Unable to find wrappers extension')
156
157 const hasExistingBucket = buckets.some((x) => x.name === values.name)
158 if (hasExistingBucket) return toast.error('Bucket name already exists')
159
160 try {
161 await createAnalyticsBucket({
162 projectRef: ref,
163 bucketName: values.name,
164 })
165
166 if (wrappersExtensionState === 'not-installed') {
167 await enableExtension({
168 projectRef: project?.ref,
169 connectionString: project?.connectionString,
170 name: wrappersExtension.name,
171 schema: wrappersExtension.schema ?? 'extensions',
172 version: wrappersExtension.default_version,
173 })
174 }
175
176 await createIcebergWrapper({ bucketName: values.name })
177
178 sendEvent({
179 action: 'storage_bucket_created',
180 properties: { bucketType: 'analytics' },
181 groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
182 })
183
184 form.reset()
185 toast.success(`Created bucket “${values.name}”`)
186 onOpenChange(false)
187 } catch (error: any) {
188 toast.error(`Failed to create bucket: ${error.message}`)
189 }
190 }
191
192 const Section = type === 'dialog' ? DialogSection : SheetSection
193 const Footer = type === 'dialog' ? DialogFooter : SheetFooter
194
195 return (
196 <>
197 <Section className="flex flex-col p-0! grow">
198 <Form {...form}>
199 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
200 <FormField
201 key="name"
202 name="name"
203 control={form.control}
204 render={({ field }) => (
205 <FormItemLayout
206 name="name"
207 className="p-5"
208 label="Bucket name"
209 labelOptional="Cannot be changed after creation"
210 description="Must be between 3 – 63 characters. Only lowercase letters, numbers, and hyphens are allowed."
211 >
212 <FormControl>
213 <Input
214 id="name"
215 data-1p-ignore
216 data-lpignore="true"
217 data-form-type="other"
218 data-bwignore
219 {...field}
220 placeholder="Enter bucket name"
221 />
222 </FormControl>
223 </FormItemLayout>
224 )}
225 />
226
227 {wrappersExtensionNeedsUpgrading ? (
228 <Admonition
229 type="warning"
230 className={cn('border-x-0 rounded-none', type === 'dialog' && 'border-b-0')}
231 title="Wrappers extension must be updated for Iceberg Wrapper support"
232 >
233 <p className="prose max-w-full text-sm leading-normal!">
234 Update the <code className="text-code-inline">wrappers</code> extension by
235 upgrading your project from your{' '}
236 <InlineLink href={`/project/${ref}/settings/infrastructure`}>
237 project settings
238 </InlineLink>{' '}
239 before creating an Analytics bucket.{' '}
240 <InlineLink href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}>
241 Learn more
242 </InlineLink>
243 .
244 </p>
245 </Admonition>
246 ) : (
247 <Admonition
248 type="default"
249 className={cn('border-x-0 rounded-none', type === 'dialog' && 'border-b-0')}
250 >
251 <p className="leading-normal!">
252 Briven will install the{' '}
253 {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''}
254 Iceberg Wrapper integration on your behalf.{' '}
255 <InlineLink href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`}>
256 Learn more
257 </InlineLink>
258 .
259 </p>
260 </Admonition>
261 )}
262 </form>
263 </Form>
264 </Section>
265
266 <Footer>
267 <Button type="default" disabled={isCreating} onClick={() => onOpenChange(false)}>
268 Cancel
269 </Button>
270 <Button
271 form={formId}
272 htmlType="submit"
273 loading={isCreating}
274 disabled={wrappersExtensionNeedsUpgrading || isCreating}
275 >
276 Create bucket
277 </Button>
278 </Footer>
279 </>
280 )
281}