diff --git a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx index 5519801208c..09c05f0e03e 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx @@ -16,7 +16,6 @@ import { FormActions } from 'components/ui/Forms/FormActions' import Panel from 'components/ui/Panel' import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation' import type { Profile } from 'data/profile/types' -import type { FormSchema } from 'types' const FormSchema = z.object({ first_name: z.string().optional(), diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index e0950b10feb..b021ef92ef7 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -14,7 +14,6 @@ import { useDatabaseFunctionCreateMutation } from 'data/database-functions/datab import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' -import type { FormSchema } from 'types' import { Button, FormControl_Shadcn_, diff --git a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx index 519a1cdbffe..c634075a8a9 100644 --- a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx +++ b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx @@ -276,7 +276,7 @@ const EmptyIntegrationConnection = React.forwardRef< onClick: () => void disabled?: boolean } ->(({ className, showNode = true, onClick, disabled, ...props }, ref) => { +>(({ className, showNode = true, onClick, disabled, orgSlug, ...props }, ref) => { return (
{ return ( + {IS_INTEGRATIONS_DIRECTORY_ENABLED && } + {IS_INTEGRATIONS_DIRECTORY_ENABLED && } diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationsDirectoryPlanNotice.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationsDirectoryPlanNotice.tsx new file mode 100644 index 00000000000..a6580264d1f --- /dev/null +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationsDirectoryPlanNotice.tsx @@ -0,0 +1,33 @@ +import { AlertCircleIcon } from 'lucide-react' +import Link from 'next/link' + +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useAppStateSnapshot } from 'state/app-state' +import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' + +const IntegrationsDirectoryPlanNotice = () => { + const snap = useAppStateSnapshot() + const selectedOrg = useSelectedOrganization() + + return ( + + + + Integrations directory is only available on the Pro plan and above + + + Go to your organization's billing settings and upgrade your plan to enable integrations + directory for this project + + + + + + ) +} + +export default IntegrationsDirectoryPlanNotice diff --git a/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/CreateIntegrationSheet.tsx b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/CreateIntegrationSheet.tsx new file mode 100644 index 00000000000..265708f2302 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/CreateIntegrationSheet.tsx @@ -0,0 +1,491 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { FileX, X } from 'lucide-react' +import { SubmitHandler, useForm } from 'react-hook-form' +import toast from 'react-hot-toast' +import z from 'zod' + +import { Markdown } from 'components/interfaces/Markdown' +import { useIntegrationDirectoryEntryCreateMutation } from 'data/integrations-directory/integration-directory-entry-create-mutation' +import { useIntegrationDirectoryEntryDeleteMutation } from 'data/integrations-directory/integration-directory-entry-delete-mutation' +import { useIntegrationDirectoryEntryUpdateMutation } from 'data/integrations-directory/integration-directory-entry-update-mutation' +import { IntegrationEntry } from 'data/integrations-directory/integrations-directory-query' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useState } from 'react' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + ExpandingTextArea, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, + Separator, + SheetClose, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, + Tabs_Shadcn_, + WarningIcon, + cn, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { DroppableTextArea } from './DroppableTextArea' +import { LogoFileInput } from './LogoFileInput' +import content from 'pages/api/projects/[ref]/content' + +const FORM_ID = 'create-integration-sidepanel' + +interface CreateIntegrationSheetProps { + integrationEntry?: IntegrationEntry + onChange: (hasChanges: boolean) => void + onClosing: (ignoreChanges: boolean) => void +} + +export const IntegrationCategory = { + api: 'API', + auth: 'Auth', + caching: 'Caching / Offline-First', + data: 'Data Platform', + devtools: 'DevTools', + fdw: 'Foreign Data Wrapper', + lowcode: 'Low-Code', + messaging: 'Messaging', + storage: 'Storage', +} as const + +const FormSchema = z.object({ + category: z.enum( + ['api', 'auth', 'caching', 'data', 'devtools', 'fdw', 'lowcode', 'messaging', 'storage'], + { + errorMap: () => { + return { message: 'Please select one of the provided categories' } + }, + } + ), + description: z.string().trim().min(1, 'The description must be at least 1 character'), + developer: z.string().trim().min(1, 'The developer must be at least 1 character'), + docs: z.string().url('The docs must be a valid URL'), + logo: z.string().url('The logo is missing, please upload an image'), + overview: z.string().trim().min(1, 'The overview must be at least 1 character'), + slug: z + .string() + .regex(/^[A-Za-z0-9_-]+$/, 'The slug must be alphanumeric and can contain - or _'), + title: z.string().trim().min(1, 'The title must be at least 1 character'), + video: z.string().optional(), + website: z.string().url('The website must be a valid URL'), +}) + +export const CreateIntegrationSheet = ({ + integrationEntry, + onChange, + onClosing, +}: CreateIntegrationSheetProps) => { + const organization = useSelectedOrganization() + const [showDeleteDraftDialog, setShowDeleteDraftDialog] = useState(false) + + const isDraft = !!integrationEntry?.parent_id || !!integrationEntry?.id + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + category: integrationEntry?.category ?? undefined, + description: integrationEntry?.description ?? '', + developer: integrationEntry?.developer ?? '', + docs: integrationEntry?.docs ?? '', + logo: integrationEntry?.logo ?? '', + overview: integrationEntry?.overview ?? '', + slug: integrationEntry?.slug ?? '', + title: integrationEntry?.title ?? '', + video: integrationEntry?.video ?? '', + website: integrationEntry?.website ?? '', + }, + }) + + const { mutate: createIntegrationEntry, isLoading: isCreating } = + useIntegrationDirectoryEntryCreateMutation() + const { mutate: updateIntegrationEntry, isLoading: isUpdating } = + useIntegrationDirectoryEntryUpdateMutation() + const { mutate: deleteIntegrationEntry, isLoading: isDeleting } = + useIntegrationDirectoryEntryDeleteMutation({ + onSuccess: () => { + if (showDeleteDraftDialog) { + toast.success('Successfully discarded draft changes.') + } + + setShowDeleteDraftDialog(false) + onClosing(true) + }, + }) + + const onSubmit: SubmitHandler> = async (data) => { + if (isDraft) { + updateIntegrationEntry( + { + orgSlug: organization!.slug, + params: { + category: data.category, + description: data.description, + developer: data.developer, + docs: data.docs, + logo: data.logo, + overview: data.overview, + slug: data.slug, + title: data.title, + video: data.video, + website: data.website, + } as any, + }, + { + onSuccess: () => { + toast.success(`Successfully updated integration`) + onClosing(true) + }, + } + ) + } else { + createIntegrationEntry( + { + orgSlug: organization!.slug, + params: { + category: data.category, + description: data.description, + developer: data.developer, + docs: data.docs, + logo: data.logo, + overview: data.overview, + slug: data.slug, + title: data.title, + video: data.video, + website: data.website, + } as any, + }, + { + onSuccess: () => { + toast.success(`Successfully added an integration entry ${data.slug}`) + onClosing(true) + }, + } + ) + } + } + + const isLoading = isCreating || isUpdating || isDeleting + + // set a variable in the parent component to indicate if the form has changes. The variable is used to show a + // confirmation dialog when the user tries to close the sheet. + form.watch(() => { + onChange(form.formState.isDirty || !form.formState.isSubmitted) + }) + + return ( + <> + +
+ + + Close + + + {isDraft ? `Update integration` : `Add integration`} + +
+
+ + +
+ + + {integrationEntry?.id && + !integrationEntry?.parent_id && + !integrationEntry?.approved && ( + + +
+ Awaiting Approval + + Your entry awaits approval by Supabase team. In the meantime, you can see the + preview{' '} + + here + + . + +
+
+ )} + + {integrationEntry?.parent_id && ( + + +
+ Draft View + + Your entry updates await approval by Supabase team. In the meantime, you can see + the preview{' '} + + here + + . + +
+
+ )} + +
+
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> +
+
+ ( + + + + + + )} + /> +
+
+ + ( + + + + + + )} + /> + ( + + + + form.setValue('category', value as keyof typeof IntegrationCategory) + } + > + + + + + {Object.entries(IntegrationCategory).map(([key, value]) => ( + + {value} + + ))} + + + + + )} + /> + + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + + ( + + + + + + )} + /> + ( + + + + + Write + Preview + + + + + + + + + + + )} + /> +
+ +
+ + {!isCreating && integrationEntry?.id && ( +
+ {integrationEntry.approved === false && ( + + )} +
+ )} + + + +
+ + setShowDeleteDraftDialog(false)} + onConfirm={() => { + deleteIntegrationEntry({ orgSlug: organization!.slug, entryId: integrationEntry?.id! }) + }} + > +

+ Are you sure you want to discard the draft changes? This action cannot be undone. +

+
+ + ) +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/DroppableTextArea.tsx b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/DroppableTextArea.tsx new file mode 100644 index 00000000000..cbe67096fa8 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/DroppableTextArea.tsx @@ -0,0 +1,176 @@ +import { useParams } from 'common' +import { uuidv4 } from 'lib/helpers' +import { + ChangeEventHandler, + Dispatch, + DragEventHandler, + SetStateAction, + useRef, + useState, +} from 'react' +import { cn, Textarea } from 'ui' +import { uploadFileToSupabaseComProject } from './upload' +import { TextArea } from '@ui/components/shadcn/ui/text-area' + +const VALID_FILE_EXTENSIONS = ['image/jpeg', 'image/png'] + +interface DroppableTextAreaProps { + value: string + onChange: Dispatch> + className?: string + disabled?: boolean +} + +const getFile = async (fileEntry: FileSystemFileEntry) => { + try { + return await new Promise((resolve, reject) => fileEntry.file(resolve, reject)) + } catch (err) { + console.error('getFile error:', err) + return undefined + } +} + +// https://stackoverflow.com/a/53058574 +const getFilesDataTransferItems = async (items: DataTransferItemList) => { + const files = [] + const queue: FileSystemEntry[] = [] + for (const item of items) { + const entry = item.webkitGetAsEntry() + if (entry) { + queue.push(entry) + } + } + while (queue.length > 0) { + const entry = queue.shift() + if (entry && entry.isFile) { + const file = await getFile(entry as FileSystemFileEntry) + if (file !== undefined) { + // file.path = entry.fullPath.slice(1) + files.push(file) + } + } + } + return files +} + +export const DroppableTextArea = ({ + value, + onChange, + disabled, + className, +}: DroppableTextAreaProps) => { + const { slug } = useParams() + const fileInputRef = useRef(null) + const [areFilesValid, setAreFilesValid] = useState<'none' | 'valid' | 'invalid'>('none') + + const onDragOver: DragEventHandler = (event) => { + if (event) { + event.stopPropagation() + event.preventDefault() + } + } + + const uploadFiles = async (items: DataTransferItemList) => { + const files = await getFilesDataTransferItems(items) + + files.forEach((file) => { + const generatedId = uuidv4() + onChange((v) => { + return v + `\n![Uploading ${file.name}…]()\n` + }) + + uploadFileToSupabaseComProject( + 'temp-for-testing-integrations', + `${slug}/${generatedId}`, + file + ).then((url) => { + onChange((v) => { + return v.replace(`![Uploading ${file.name}…]()`, `![${file.name}](${url})`) + }) + }) + }) + } + + const onDrop: DragEventHandler = async (event) => { + onDragOver(event) + + if (areFilesValid === 'valid') { + const items = (event as any).target.files || (event as any).dataTransfer.items + uploadFiles(items) + } + } + + const checkFiles: DragEventHandler = (event) => { + const items = event.dataTransfer.items || [] + if (items.length === 0) { + setAreFilesValid('none') + } + for (const item of items) { + if (!VALID_FILE_EXTENSIONS.includes(item.type)) { + setAreFilesValid('invalid') + return + } + } + setAreFilesValid('valid') + } + + const onUploadClick = () => { + if (disabled) return + fileInputRef.current?.click() + } + + const onInputChange: ChangeEventHandler = async (event) => { + const items = fileInputRef.current?.files || [] + if (items.length > 0) { + // Create a new DataTransfer object + const list = new DataTransfer() + + // Add each file to the DataTransfer object + for (const file of items) { + list.items.add(file) + } + uploadFiles(list.items) + } + } + + return ( +
+