mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Vercel install and project-link failures use transient toasts. ## What is the new behavior? Failures remain visible below the relevant action and clear when the user retries or changes a selection. The Vercel mutation hooks expose errors without choosing their presentation. Interstitial callers render them inline, while existing non-interstitial callers explicitly retain their toasts. | Before | After | | --- | --- | | <img width="1024" height="759" alt="Install Vercel Integration Supabase" src="https://github.com/user-attachments/assets/6348cdd2-220a-4ad8-89f9-7fc51de36a3c" /> | <img width="1024" height="759" alt="Install Vercel Integration Supabase" src="https://github.com/user-attachments/assets/ecc50c4e-daab-4f6d-bf86-7282e2aff92c" /> | ## To test 1. Switch to `dnywh/inline-vercel-errors` (this branch). 2. Open `apps/studio/pages/integrations/vercel/install.tsx`. 3. Find the `actionError` assignment immediately below `useVercelIntegrationCreateMutation` and replace the whole assignment with: ```tsx const actionError = 'Creating Vercel integration failed: Test error' ``` 4. With local Studio running and while signed in, open `http://localhost:8082/integrations/vercel/install?code=test&configurationId=test&source=marketplace` 5. Confirm the error remains visible below **Install integration**. No Vercel installation or real authorisation code is required. 6. Revert the temporary edit. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Bug Fixes * Improved error handling across Vercel project connection and installation flows. * Validation, duplicate-connection, and connection failures now appear inline in the relevant setup steps. * Errors clear automatically when the selected project or organization changes. * Notifications remain available in supported flows, including new project creation and side-panel setup. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import { useParams } from 'common'
|
|
import Head from 'next/head'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from 'ui'
|
|
import { Admonition } from 'ui-patterns/Admonition'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import {
|
|
VercelIntegrationFooter,
|
|
VercelIntegrationInterstitialErrorState,
|
|
VercelIntegrationLogo,
|
|
} from '@/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial'
|
|
import { getHasInstalledObject } from '@/components/layouts/IntegrationsLayout/Integrations.utils'
|
|
import {
|
|
InterstitialAccountRow,
|
|
InterstitialActionError,
|
|
InterstitialLayout,
|
|
} from '@/components/layouts/InterstitialLayout'
|
|
import { useIntegrationsQuery } from '@/data/integrations/integrations-query'
|
|
import { useVercelIntegrationCreateMutation } from '@/data/integrations/vercel-integration-create-mutation'
|
|
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
|
|
import { withAuth } from '@/hooks/misc/withAuth'
|
|
import {
|
|
buildVercelInstallRouteQuery,
|
|
getErrorMessage,
|
|
resolveVercelInstallSource,
|
|
} from '@/lib/integrations/vercel-install.utils'
|
|
import { buildStudioPageTitle } from '@/lib/page-title'
|
|
import { useProfileNameAndPicture } from '@/lib/profile'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { useIntegrationInstallationSnapshot } from '@/state/integration-installation'
|
|
import type { NextPageWithLayout, Organization } from '@/types'
|
|
|
|
const PAGE_TITLE = buildStudioPageTitle({
|
|
section: 'Install Vercel Integration',
|
|
brand: 'Supabase',
|
|
})
|
|
|
|
/**
|
|
* Variations of the Vercel integration flow.
|
|
* They require different UI and logic.
|
|
*
|
|
* Deploy Button - the flow that starts from the Deploy Button - https://vercel.com/docs/integrations#deploy-button
|
|
* Marketplace - the flow that starts from the Marketplace - https://vercel.com/integrations
|
|
*
|
|
*/
|
|
export type VercelIntegrationFlow = 'deploy-button' | 'marketing'
|
|
|
|
const VercelIntegration: NextPageWithLayout = () => {
|
|
const track = useTrack()
|
|
const router = useRouter()
|
|
const snapshot = useIntegrationInstallationSnapshot()
|
|
const { code, configurationId, currentProjectId, externalId, next, teamId, source } = useParams()
|
|
|
|
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null)
|
|
const [validationError, setValidationError] = useState<string>()
|
|
|
|
const { username, primaryEmail, avatarUrl } = useProfileNameAndPicture()
|
|
const displayName = primaryEmail ?? username ?? ''
|
|
|
|
/**
|
|
* Fetch the list of organization based integration installations for Vercel.
|
|
*
|
|
* Array of integrations installed on all
|
|
*/
|
|
const {
|
|
data: integrationData,
|
|
isPending: isLoadingIntegrationsQuery,
|
|
isError: isIntegrationsError,
|
|
error: integrationsError,
|
|
} = useIntegrationsQuery()
|
|
|
|
const {
|
|
data: organizationsData,
|
|
isPending: isLoadingOrganizationsQuery,
|
|
isSuccess: isOrganizationsDataSuccess,
|
|
isError: isOrganizationsError,
|
|
error: organizationsError,
|
|
} = useOrganizationsQuery()
|
|
|
|
/**
|
|
* Organizations with extra `installationInstalled` attribute
|
|
*
|
|
* Used to show label/badge and allow/disallow installing
|
|
*
|
|
*/
|
|
const installed = useMemo(
|
|
() =>
|
|
integrationData && organizationsData
|
|
? getHasInstalledObject({
|
|
integrationName: 'Vercel',
|
|
integrationData,
|
|
organizationsData,
|
|
installationId: configurationId,
|
|
})
|
|
: {},
|
|
[configurationId, integrationData, organizationsData]
|
|
)
|
|
|
|
/**
|
|
* Handle the correct route change based on whether the vercel integration
|
|
* is following the 'marketplace/external' flow or 'deploy button' flow.
|
|
* See:
|
|
* - https://vercel.com/docs/integrations/create-integration/submit-integration#query-parameters-for-marketplace
|
|
* - https://vercel.com/docs/integrations/create-integration/submit-integration#query-parameters-for-external-flow
|
|
* - https://vercel.com/docs/integrations/create-integration/submit-integration#query-parameters-for-deploy-button
|
|
*/
|
|
function handleRouteChange() {
|
|
const orgSlug = selectedOrg?.slug
|
|
const vercelInstallSource = resolveVercelInstallSource({
|
|
source,
|
|
currentProjectId,
|
|
externalId,
|
|
})
|
|
const query = buildVercelInstallRouteQuery({
|
|
source: vercelInstallSource,
|
|
organizationSlug: orgSlug,
|
|
configurationId,
|
|
currentProjectId,
|
|
externalId,
|
|
next,
|
|
})
|
|
|
|
switch (vercelInstallSource) {
|
|
case 'deploy-button': {
|
|
router.push({
|
|
pathname: `/integrations/vercel/${orgSlug}/deploy-button/new-project`,
|
|
query,
|
|
})
|
|
break
|
|
}
|
|
case 'marketplace':
|
|
case 'external': {
|
|
router.push({
|
|
pathname: `/integrations/vercel/${orgSlug}/marketplace/choose-project`,
|
|
query,
|
|
})
|
|
break
|
|
}
|
|
default:
|
|
setValidationError(
|
|
`Unsupported Vercel installation source: ${source}. Please contact support if this error persists.`
|
|
)
|
|
}
|
|
}
|
|
|
|
const {
|
|
mutate,
|
|
isPending: isLoadingVercelIntegrationCreateMutation,
|
|
error: createIntegrationError,
|
|
reset: resetCreateIntegrationError,
|
|
} = useVercelIntegrationCreateMutation({
|
|
onMutate() {
|
|
snapshot.setLoading(true)
|
|
},
|
|
onSuccess() {
|
|
handleRouteChange()
|
|
snapshot.setLoading(false)
|
|
},
|
|
onError() {
|
|
snapshot.setLoading(false)
|
|
},
|
|
})
|
|
const actionError =
|
|
validationError ??
|
|
(createIntegrationError
|
|
? `Creating Vercel integration failed: ${createIntegrationError.message}`
|
|
: undefined)
|
|
|
|
function onInstall() {
|
|
setValidationError(undefined)
|
|
resetCreateIntegrationError()
|
|
const orgSlug = selectedOrg?.slug
|
|
|
|
const isIntegrationInstalled = orgSlug ? installed[orgSlug] : false
|
|
|
|
if (!orgSlug) {
|
|
return setValidationError('Please select an organization')
|
|
}
|
|
|
|
if (!code) {
|
|
return setValidationError('Vercel code missing')
|
|
}
|
|
|
|
if (!configurationId) {
|
|
return setValidationError('Vercel configuration ID missing')
|
|
}
|
|
|
|
if (!source) {
|
|
return setValidationError('Vercel configuration source missing')
|
|
}
|
|
|
|
/**
|
|
* Only install if integration hasn't already been installed
|
|
*/
|
|
if (!isIntegrationInstalled) {
|
|
track(
|
|
'integration_install_submitted',
|
|
{ integrationName: 'Vercel', method: source },
|
|
{ organization: orgSlug }
|
|
)
|
|
mutate({
|
|
code,
|
|
configurationId,
|
|
orgSlug,
|
|
metadata: {},
|
|
source,
|
|
teamId: teamId,
|
|
})
|
|
} else {
|
|
handleRouteChange()
|
|
}
|
|
}
|
|
|
|
const dataLoading =
|
|
isLoadingVercelIntegrationCreateMutation ||
|
|
isLoadingOrganizationsQuery ||
|
|
isLoadingIntegrationsQuery
|
|
|
|
const noOrganizations = useMemo(() => {
|
|
return isOrganizationsDataSuccess && organizationsData?.length === 0 ? true : false
|
|
}, [isOrganizationsDataSuccess, organizationsData])
|
|
|
|
const missingParams = [
|
|
!code ? 'code' : undefined,
|
|
!configurationId ? 'configurationId' : undefined,
|
|
!source ? 'source' : undefined,
|
|
].filter(Boolean) as string[]
|
|
|
|
const isError = isOrganizationsError || isIntegrationsError
|
|
const errorMessage = getErrorMessage(organizationsError) ?? getErrorMessage(integrationsError)
|
|
const showLoadingState = isLoadingOrganizationsQuery || isLoadingIntegrationsQuery
|
|
|
|
const disableInstallationForm =
|
|
dataLoading || noOrganizations || !selectedOrg || missingParams.length > 0 || isError
|
|
|
|
useEffect(() => {
|
|
if (organizationsData !== undefined && integrationData !== undefined) {
|
|
const firstOrg = organizationsData[0]
|
|
|
|
if (firstOrg && selectedOrg === null) {
|
|
setSelectedOrg(firstOrg)
|
|
}
|
|
}
|
|
}, [organizationsData, integrationData, selectedOrg])
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{PAGE_TITLE}</title>
|
|
</Head>
|
|
|
|
<InterstitialLayout
|
|
logo={<VercelIntegrationLogo />}
|
|
title="Install Vercel integration"
|
|
description="Choose the Supabase organization Vercel can connect to"
|
|
footer={<VercelIntegrationFooter />}
|
|
>
|
|
<div className="px-6 pb-6">
|
|
{showLoadingState ? (
|
|
<InstallationLoadingState />
|
|
) : isError ? (
|
|
<VercelIntegrationInterstitialErrorState
|
|
title="Unable to load installation"
|
|
errorMessage={errorMessage}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col gap-5">
|
|
<InterstitialAccountRow avatarUrl={avatarUrl} displayName={displayName} />
|
|
|
|
<OrganizationSelect
|
|
organizations={organizationsData ?? []}
|
|
selectedOrg={selectedOrg}
|
|
disabled={noOrganizations || dataLoading}
|
|
installed={installed}
|
|
onSelectedOrgChange={(organization) => {
|
|
setSelectedOrg(organization)
|
|
setValidationError(undefined)
|
|
resetCreateIntegrationError()
|
|
}}
|
|
/>
|
|
|
|
{missingParams.length > 0 && (
|
|
<Admonition
|
|
type="warning"
|
|
title="Missing Vercel installation details"
|
|
description={`Retry from Vercel. The installation URL is missing: ${missingParams.join(
|
|
', '
|
|
)}.`}
|
|
/>
|
|
)}
|
|
|
|
{noOrganizations && (
|
|
<Admonition
|
|
type="warning"
|
|
title="No Supabase organizations found"
|
|
description={
|
|
<>
|
|
Create a Supabase organization before installing the Vercel integration. You
|
|
can create a new organization{' '}
|
|
<Link href="https://supabase.com/dashboard/new" target="_blank">
|
|
here
|
|
</Link>
|
|
.
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Button
|
|
block
|
|
variant="primary"
|
|
disabled={disableInstallationForm}
|
|
loading={dataLoading}
|
|
onClick={onInstall}
|
|
>
|
|
{selectedOrg && installed[selectedOrg.slug] ? 'Continue' : 'Install integration'}
|
|
</Button>
|
|
<InterstitialActionError error={actionError} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</InterstitialLayout>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const InstallationLoadingState = () => (
|
|
<div className="flex flex-col gap-5">
|
|
<Card className="shadow-none">
|
|
<CardContent className="flex items-center gap-3 border-none px-4 py-3">
|
|
<ShimmeringLoader className="size-8 flex-shrink-0 rounded-full py-0" />
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<ShimmeringLoader className="h-3 w-20 py-0" />
|
|
<ShimmeringLoader className="h-4 w-40 max-w-full py-0" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<section className="space-y-2" aria-label="Organization loading">
|
|
<ShimmeringLoader className="h-3 w-24 py-0" />
|
|
<ShimmeringLoader className="h-[34px] w-full rounded-md py-0" />
|
|
</section>
|
|
<ShimmeringLoader className="h-10 w-full rounded-md py-0" />
|
|
</div>
|
|
)
|
|
|
|
interface OrganizationSelectProps {
|
|
organizations: Organization[]
|
|
selectedOrg: Organization | null
|
|
disabled?: boolean
|
|
installed: Record<string, boolean>
|
|
onSelectedOrgChange: (organization: Organization) => void
|
|
}
|
|
|
|
function OrganizationSelect({
|
|
organizations,
|
|
selectedOrg,
|
|
disabled,
|
|
installed,
|
|
onSelectedOrgChange,
|
|
}: OrganizationSelectProps) {
|
|
return (
|
|
<section className="space-y-2" aria-label="Organization">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
|
|
Organization
|
|
</p>
|
|
<Select
|
|
value={selectedOrg?.slug ?? ''}
|
|
disabled={disabled}
|
|
onValueChange={(slug) => {
|
|
const org = organizations.find((org) => org.slug === slug)
|
|
if (org) onSelectedOrgChange(org)
|
|
}}
|
|
>
|
|
<SelectTrigger size="small" aria-label="Supabase organization to install Vercel into">
|
|
<SelectValue placeholder="Choose an organization" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{organizations.map((org) => (
|
|
<SelectItem key={org.slug} value={org.slug} className="text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate">{org.name}</span>
|
|
{installed[org.slug] && <Badge className="flex-none!">Installed</Badge>}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default withAuth(VercelIntegration)
|