diff --git a/studio/components/interfaces/Integrations/OrganizationPicker.tsx b/studio/components/interfaces/Integrations/OrganizationPicker.tsx index 5b6f3fe4d7c..bbf831193be 100644 --- a/studio/components/interfaces/Integrations/OrganizationPicker.tsx +++ b/studio/components/interfaces/Integrations/OrganizationPicker.tsx @@ -6,6 +6,9 @@ import { useMemo, useRef, useState } from 'react' import { Organization } from 'types' import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, Badge, Button, CommandEmpty_Shadcn_, @@ -14,11 +17,13 @@ import { CommandItem_Shadcn_, CommandList_Shadcn_, Command_Shadcn_, + IconAlertCircle, IconChevronDown, IconHexagon, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, + cn, } from 'ui' export interface OrganizationPickerProps { @@ -26,6 +31,7 @@ export interface OrganizationPickerProps { configurationId?: string selectedOrg: Organization | null onSelectedOrgChange: (organization: Organization) => void + disabled?: boolean } const OrganizationPicker = ({ @@ -33,6 +39,7 @@ const OrganizationPicker = ({ configurationId, selectedOrg, onSelectedOrgChange, + disabled, }: OrganizationPickerProps) => { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -63,21 +70,22 @@ const OrganizationPicker = ({ size="medium" block className="justify-start" - icon={} loading={isLoadingOrganization} - disabled={isLoadingOrganization} + disabled={disabled} iconRight={ } > - - {selectedOrg?.name} +
+ + {selectedOrg?.name ? selectedOrg?.name : 'Choose an organization'} + {selectedOrg && configurationId && installed[selectedOrg.slug] && ( Integration Installed )} - +
- {org.name}{' '} {configurationId && installed[org.slug] && ( diff --git a/studio/components/interfaces/Integrations/ProjectLinker.tsx b/studio/components/interfaces/Integrations/ProjectLinker.tsx index 2bebe5af4b7..e8abddfd9f6 100644 --- a/studio/components/interfaces/Integrations/ProjectLinker.tsx +++ b/studio/components/interfaces/Integrations/ProjectLinker.tsx @@ -19,6 +19,8 @@ import { Popover_Shadcn_, cn, } from 'ui' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' export interface Project { id: string @@ -132,6 +134,11 @@ const ProjectLinker = ({ ) } + const noSupabaseProjects = supabaseProjects.length === 0 + const noForeignProjects = foreignProjects.length === 0 + const missingEntity = noSupabaseProjects ? 'Supabase' : 'Vercel' + const oppositeMissingEntity = noSupabaseProjects ? 'Vercel' : 'Supabase' + return (
@@ -139,151 +146,164 @@ const ProjectLinker = ({ className="absolute inset-0 bg-grid-black/5 [mask-image:linear-gradient(0deg,#fff,rgba(255,255,255,0.6))] dark:bg-grid-white/5 dark:[mask-image:linear-gradient(0deg,rgba(255,255,255,0.1),rgba(255,255,255,0.5))]" style={{ backgroundPosition: '10px 10px' }} >
-
- -
- Supabase -
+ {(noSupabaseProjects || noForeignProjects) && + (!loadingForeignProjects || !loadingSupabaseProjects) ? ( +
+
No {missingEntity} Projects found
+

+ You will need to create a {missingEntity} Project to link to a {oppositeMissingEntity}{' '} + Project. +
+ You can skip this and create a Project Connection later. +

+
+ ) : ( +
+ +
+ Supabase +
- - -
- } - iconRight={ - defaultSupabaseProjectRef === undefined ? ( + + +
+ } + iconRight={ + defaultSupabaseProjectRef === undefined ? ( + + + + ) : null + } + > + {selectedSupabaseProject ? selectedSupabaseProject.name : 'Choose Project'} + + + + + + + No results found. + + {supabaseProjects.map((project, i) => { + return ( + { + if (project.ref) setSupabaseProjectRef(project.ref) + setSupabaseProjectsComboboxOpen(false) + }} + > +
+ Supabase +
+ {project.name} +
+ ) + })} +
+
+
+
+ + +
+ +
+ {integrationIcon} +
+ + + + + + - {selectedSupabaseProject ? selectedSupabaseProject.name : 'Choose Project'} - - - - - - - No results found. - - {supabaseProjects.map((project, i) => { - return ( - { - if (project.ref) setSupabaseProjectRef(project.ref) - setSupabaseProjectsComboboxOpen(false) - }} - > -
- Supabase -
- {project.name} -
- ) - })} -
-
-
-
-
-
-
- -
- {integrationIcon} -
- - - - - - - - - - No results found. - - {foreignProjects.map((project, i) => { - return ( - { - if (project.id) setForeignProjectId(project.id) - setForeignProjectsComboboxOpen(false) - }} - > - {getForeignProjectIcon?.(project) ?? integrationIcon} - {project.name} - - ) - })} - - - - - -
-
+ + + + No results found. + + {foreignProjects.map((project, i) => { + return ( + { + if (project.id) setForeignProjectId(project.id) + setForeignProjectsComboboxOpen(false) + }} + > + {getForeignProjectIcon?.(project) ?? integrationIcon} + {project.name} + + ) + })} + + + +
+ + + + )}
{onSkip !== undefined && ( diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx index f2b092bf758..e4fb4d93012 100644 --- a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -120,7 +120,7 @@ const SidePanelVercelProjectLinker = ({ return ( { - return ` - \n - - \`${x}\` -` + return `\n - \`${x}\`` })} `} /> diff --git a/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx b/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx index 4aa3e3a6a81..7db53f8073e 100644 --- a/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx +++ b/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx @@ -2,18 +2,23 @@ import { useParams } from 'common' import { useFlag, withAuth } from 'hooks' import { BASE_PATH } from 'lib/constants' import { PropsWithChildren, ReactNode, forwardRef } from 'react' -import { IconX, cn } from 'ui' +import { IconX, cn, LoadingLine, IconBook, IconLifeBuoy } from 'ui' import { ScaffoldContainer } from '../Scaffold' +import Link from 'next/link' export type IntegrationWindowLayoutProps = { title: string integrationIcon: ReactNode + loading?: boolean + docsHref?: string } const IntegrationWindowLayout = ({ title, integrationIcon, children, + loading = false, + docsHref, }: PropsWithChildren) => { const ongoingIncident = useFlag('ongoingIncident') const maxHeight = ongoingIncident ? 'calc(100vh - 44px)' : '100vh' @@ -21,8 +26,30 @@ const IntegrationWindowLayout = ({ return (
- - {children} + +
{children}
+ + {docsHref && ( + + + Docs + + + )} + + + Support + + +
) } diff --git a/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx b/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx index a70e1ef9bae..53e287a291a 100644 --- a/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx +++ b/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react' import { useParams } from 'common' import { BASE_PATH } from 'lib/constants' import IntegrationWindowLayout from './IntegrationWindowLayout' +import { useIntegrationInstallationSnapshot } from 'state/integration-installation' const VERCEL_ICON = (
@@ -13,12 +14,19 @@ const VERCEL_ICON = ( const VercelIntegrationWindowLayout = ({ children }: PropsWithChildren<{}>) => { const { externalId } = useParams() + const snapshot = useIntegrationInstallationSnapshot() + const title = externalId ? 'Supabase + Vercel Deploy Button' : 'Supabase + Vercel Integration Marketplace Connector' return ( - + {children} ) diff --git a/studio/components/layouts/VercelIntegrationLayout.tsx b/studio/components/layouts/VercelIntegrationLayout.tsx index 7390fe53152..8c42daffedb 100644 --- a/studio/components/layouts/VercelIntegrationLayout.tsx +++ b/studio/components/layouts/VercelIntegrationLayout.tsx @@ -10,8 +10,7 @@ const VercelIntegrationLayout = ({ children }: PropsWithChildren<{}>) => { Supabase + Vercel - -
+
@@ -29,7 +28,7 @@ const VercelIntegrationLayout = ({ children }: PropsWithChildren<{}>) => {
-
+
{children}
diff --git a/studio/data/integrations/integrations-vercel-connections-create-mutation.ts b/studio/data/integrations/integrations-vercel-connections-create-mutation.ts index 9ea64f86836..63c3c1fb309 100644 --- a/studio/data/integrations/integrations-vercel-connections-create-mutation.ts +++ b/studio/data/integrations/integrations-vercel-connections-create-mutation.ts @@ -5,6 +5,7 @@ import { post } from 'data/fetchers' import { ResponseError } from 'types' import { integrationKeys } from './keys' import { IntegrationConnectionsCreateVariables } from './types' +import { useIntegrationInstallationSnapshot } from 'state/integration-installation' export async function createIntegrationVercelConnections({ organizationIntegrationId, @@ -44,6 +45,7 @@ export const useIntegrationVercelConnectionsCreateMutation = ({ 'mutationFn' > = {}) => { const queryClient = useQueryClient() + const snapshot = useIntegrationInstallationSnapshot() return useMutation< IntegrationVercelConnectionsCreateData, ResponseError, @@ -63,6 +65,7 @@ export const useIntegrationVercelConnectionsCreateMutation = ({ await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { + snapshot.setLoading(false) if (onError === undefined) { toast.error(`Failed to create connection: ${data.message}`) } else { diff --git a/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index 9e302c67622..e7d4723d40c 100644 --- a/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -19,6 +19,8 @@ import { BASE_PATH } from 'lib/constants' import { EMPTY_ARR } from 'lib/void' import { NextPageWithLayout, Organization } from 'types' import { IconBook, IconLifeBuoy, LoadingLine } from 'ui' +import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' +import { useIntegrationInstallationSnapshot } from 'state/integration-installation' const VERCEL_ICON = ( Vercel Icon @@ -93,6 +95,8 @@ const VercelIntegration: NextPageWithLayout = () => { [vercelProjectsById] ) + const snapshot = useIntegrationInstallationSnapshot() + const { mutateAsync: syncEnvs } = useIntegrationsVercelConnectionSyncEnvsMutation() const { mutate: createConnections, isLoading: isCreatingConnection } = useIntegrationVercelConnectionsCreateMutation({ @@ -100,13 +104,22 @@ const VercelIntegration: NextPageWithLayout = () => { try { await syncEnvs({ connectionId: id }) } catch (error: any) { + snapshot.setLoading(false) toast.error('Failed to sync environment variables: ', error.message) } if (next) { + snapshot.setLoading(false) window.location.href = next } }, + onMutate() { + snapshot.setLoading(true) + }, + onError(error) { + snapshot.setLoading(false) + toast.error(`Creating connection failed: ${error.message}`) + }, }) const onCreateConnections = useCallback( @@ -131,71 +144,52 @@ const VercelIntegration: NextPageWithLayout = () => { return ( <> - -
- <> - - -
-

- Link a Supabase project to a Vercel project -

- + +
+

Create your first Project Connection

+ -
- - { - if (next) { - window.location.href = next - } - }} - loadingForeignProjects={isLoadingVercelProjectsData} - loadingSupabaseProjects={isLoadingSupabaseProjectsData} - /> - +
+ { + if (next) { + window.location.href = next + } + }} + loadingForeignProjects={isLoadingVercelProjectsData} + loadingSupabaseProjects={isLoadingSupabaseProjectsData} + /> + { - return ` - \n - - \`${x}\` -` + return `\n - \`${x}\`` })} `} - /> -
-
- - - -
- -
- Docs -
-
- Support -
+ /> +
) } -VercelIntegration.getLayout = (page) => {page} +VercelIntegration.getLayout = (page) => ( + {page} +) export default VercelIntegration diff --git a/studio/pages/integrations/vercel/install.tsx b/studio/pages/integrations/vercel/install.tsx index 95419081f5b..2b023802ef0 100644 --- a/studio/pages/integrations/vercel/install.tsx +++ b/studio/pages/integrations/vercel/install.tsx @@ -7,11 +7,12 @@ import OrganizationPicker from 'components/interfaces/Integrations/OrganizationP import { Markdown } from 'components/interfaces/Markdown' import { getHasInstalledObject } from 'components/layouts/IntegrationsLayout/Integrations.utils' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' -import { ScaffoldColumn, ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import { ScaffoldColumn, ScaffoldContainer } from 'components/layouts/Scaffold' import { useIntegrationsQuery } from 'data/integrations/integrations-query' import { useVercelIntegrationCreateMutation } from 'data/integrations/vercel-integration-create-mutation' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useStore } from 'hooks' +import Link from 'next/link' import { NextPageWithLayout, Organization } from 'types' import { AlertDescription_Shadcn_, @@ -19,11 +20,9 @@ import { Alert_Shadcn_, Button, IconAlertTriangle, - IconBook, IconInfo, - IconLifeBuoy, - LoadingLine, } from 'ui' +import { useIntegrationInstallationSnapshot } from 'state/integration-installation' /** * Variations of the Vercel integration flow. @@ -42,6 +41,8 @@ const VercelIntegration: NextPageWithLayout = () => { const [selectedOrg, setSelectedOrg] = useState(null) const [organizationIntegrationId, setOrganizationIntegrationId] = useState(null) + const snapshot = useIntegrationInstallationSnapshot() + /** * Fetch the list of organization based integration installations for Vercel. * @@ -49,8 +50,11 @@ const VercelIntegration: NextPageWithLayout = () => { */ const { data: integrationData } = useIntegrationsQuery() - const { data: organizationsData, isLoading: isLoadingOrganizationsQuery } = - useOrganizationsQuery() + const { + data: organizationsData, + isLoading: isLoadingOrganizationsQuery, + isSuccess: isOrganizationsDataSuccess, + } = useOrganizationsQuery() useEffect(() => { if (organizationsData !== undefined && integrationData !== undefined) { @@ -105,10 +109,13 @@ const VercelIntegration: NextPageWithLayout = () => { const { mutate, isLoading: isLoadingVercelIntegrationCreateMutation } = useVercelIntegrationCreateMutation({ + onMutate() { + snapshot.setLoading(true) + }, onSuccess({ id }) { setOrganizationIntegrationId(id) - handleRouteChange() + snapshot.setLoading(false) }, onError(error) { toast.error(`Creating Vercel integration failed: ${error.message}`) @@ -158,81 +165,90 @@ const VercelIntegration: NextPageWithLayout = () => { const dataLoading = isLoadingVercelIntegrationCreateMutation || isLoadingOrganizationsQuery + const noOrganizations = useMemo(() => { + return isOrganizationsDataSuccess && organizationsData?.length === 0 ? true : false + }, [isOrganizationsDataSuccess, organizationsData]) + + const alreadyInstalled = useMemo(() => { + return selectedOrg && installed[selectedOrg.slug] && source === 'marketplace' && !dataLoading + ? true + : false + }, [installed, selectedOrg, source, dataLoading]) + const disableInstallationForm = (isLoadingVercelIntegrationCreateMutation && !dataLoading) || // disables installation button if integration is already installed and it is Marketplace flow - (selectedOrg && installed[selectedOrg.slug] && source === 'marketplace' && !dataLoading) - ? true - : false + alreadyInstalled || + noOrganizations + + const isLoading = useMemo(() => { + return isLoadingVercelIntegrationCreateMutation || isLoadingOrganizationsQuery + }, [isLoadingVercelIntegrationCreateMutation, isLoadingOrganizationsQuery]) return ( <> -
- - {organizationIntegrationId === null && ( + + +

Choose organization

<> - - -

Choose organization

- <> - - { - setSelectedOrg(org) - router.query.organizationSlug = org.slug - }} - configurationId={configurationId} - /> - {disableInstallationForm && ( - - - - Vercel Integration is already installed. - - - You will need to choose another organization to install the integration. - - - )} -
- -
- -
-
- - - - - You can uninstall this Integration at any time. - + + { + setSelectedOrg(org) + router.query.organizationSlug = org.slug + }} + configurationId={configurationId} + /> + {alreadyInstalled && ( + + + Vercel Integration is already installed. - Remove this integration at any time via Vercel or the Supabase dashboard. + You will need to choose another organization to install the integration. - + )} + {noOrganizations && ( + + + + No Supabase Organizations to install Integration. + + + You will need to create a Supabase Organization before you can install the Vercel + Integration. You can create a new organization{' '} + + here + + . + + + )} +
+ +
- )} - - -
- -
- Docs -
-
- Support -
+ +
+ + + + You can uninstall this Integration at any time. + + Remove this integration at any time via Vercel or the Supabase dashboard. + + ) diff --git a/studio/state/integration-installation.ts b/studio/state/integration-installation.ts new file mode 100644 index 00000000000..af049f84baf --- /dev/null +++ b/studio/state/integration-installation.ts @@ -0,0 +1,13 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export const integrationInstallationState = proxy({ + loading: false as boolean, + setLoading: (boolean: boolean) => { + integrationInstallationState.loading = boolean + }, +}) + +export const getIntegrationInstallation = () => snapshot(integrationInstallationState) + +export const useIntegrationInstallationSnapshot = (options?: Parameters[1]) => + useSnapshot(integrationInstallationState, options)