CreateAnalyticsBucketForm.tsx 9.5 KB

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