Files
supabase/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx
Ivan Vasilov 590ed362ab feat: Redesign for the integrations pages (#30476)
* Initial commit.

* Minor type fixes.

* Add a integration for Queues. Refactor some of the integration layout.

* Migrate the Cron integration to the new style.

* Add useInstalledIntegrations hook.

* Add an integration entry for vault.

* Add an integration entry for GraphiQL.

* Add supabase webhooks.

* Feat/integrations get layout (#30538)

* scroll based icon

* Update header.tsx

* remove dep from overview

* moar

* more table stuff

* moar

* alt nav put in

* fix MotionNumber issues

* more

* trying both layouts

* Fix bunch of type errors.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>

* Migrate Vercel and Github files to their own folders.

* Switch all integrations with the new designs.

* More fixes for links, pages structure and other random stuff.

* Remove unneeded file.

* Another set of fixes. Migrated most of the extension integrations.

* Migrated Vault and webhooks to the new style.

* Various fixes to make the animation work.

* Remove extra code.

* Tiny fixes 😬 i swear its tiny

* Refactor IntegrationOverviewTab

* chore/ update integrations routes (#30585)

* init

* add child support in tabs

* add webhooks

* Update IntegrationPageHandler.tsx

* fix id issues

* use messageId instead

* animation tweaks

* Move the description to the wrappers array.

* The useInstalledIntegrations now provides integrations which could be installed.

* Add static content for the various integrations.

* Move the page handler logic into the integrations definitions.

* Clean up some extra code.

* Add logic to make the overview tab the default tab.

* Don't show the header until the integration id has been checked.

* Add logic to the integration pages to avoid weird loading bugs, deselecting tabs if the integration hasn't been installed etc.

* Fix the webhooks overview tab.

* Fix the buttons for enabling extensions.

* Add padding to all custom tab contents.

* Small fixes

* Prettier lint

* Fix icon color + add empty state for when available integrations are all installed

* Fix ts errors

* Fiox

* Add enable webhooks cta

* Fix key

* Fix all lints

* Fix the queues create sheet.

* Fix the deletion of wrappers.

* Fix the minimum version alert for the wrappers extension.

* Make the queues table fit the whole container.

* Fix an issue which reset the tab when installing an extension.

* Address comments

* Add loading state for installed integrations in side nav

* Fix edit secret not rendering value in input field after subsequent openings

* Fix vault keys auto filling search input with vault

* Fix search input placeholder for cron

* Minor fix in install database extension copy

* Fix a bad redirect when reloading.

* Fix bad url redirects.

* Fix scrolling in create new/edit wrapper sheet.

* Add y padding to the wrappers rows.

* Fix merge errors.

* More merge fixes.

* Fix bad imports during the merge.

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2024-11-25 13:26:22 +01:00

412 lines
16 KiB
TypeScript

import { ChevronDown, PlusIcon } from 'lucide-react'
import { useRouter } from 'next/router'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import ShimmerLine from 'components/ui/ShimmerLine'
import {
IntegrationConnectionsCreateVariables,
IntegrationProjectConnection,
} from 'data/integrations/integrations.types'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { BASE_PATH } from 'lib/constants'
import { openInstallGitHubIntegrationWindow } from 'lib/github'
import { EMPTY_ARR } from 'lib/void'
import {
Button,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
Command_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
cn,
} from 'ui'
export interface Project {
name: string
ref: string
}
export interface ForeignProject {
id: string
name: string
installation_id?: number
}
export interface ProjectLinkerProps {
organizationIntegrationId?: string
foreignProjects: ForeignProject[]
supabaseProjects: Project[]
onCreateConnections: (variables: IntegrationConnectionsCreateVariables) => void
installedConnections?: IntegrationProjectConnection[]
isLoading?: boolean
integrationIcon: ReactNode
getForeignProjectIcon?: (project: ForeignProject) => ReactNode
choosePrompt?: string
onSkip?: () => void
loadingForeignProjects?: boolean
loadingSupabaseProjects?: boolean
showNoEntitiesState?: boolean
defaultSupabaseProjectRef?: string
defaultForeignProjectId?: string
mode: 'Vercel' | 'GitHub'
}
const ProjectLinker = ({
organizationIntegrationId,
foreignProjects,
supabaseProjects,
onCreateConnections: _onCreateConnections,
installedConnections = EMPTY_ARR,
isLoading,
integrationIcon,
getForeignProjectIcon,
choosePrompt = 'Choose a project',
onSkip,
loadingForeignProjects,
loadingSupabaseProjects,
showNoEntitiesState = true,
defaultSupabaseProjectRef,
defaultForeignProjectId,
mode,
}: ProjectLinkerProps) => {
const router = useRouter()
const [supabaseProjectsComboBoxOpen, setSupabaseProjectsComboboxOpen] = useState(false)
const [foreignProjectsComboBoxOpen, setForeignProjectsComboboxOpen] = useState(false)
const supabaseProjectsComboBoxRef = useRef<HTMLButtonElement>(null)
const foreignProjectsComboBoxRef = useRef<HTMLButtonElement>(null)
const selectedOrganization = useSelectedOrganization()
const [supabaseProjectRef, setSupabaseProjectRef] = useState<string | undefined>(
defaultSupabaseProjectRef
)
useEffect(() => {
if (defaultSupabaseProjectRef !== undefined && supabaseProjectRef === undefined)
setSupabaseProjectRef(defaultSupabaseProjectRef)
}, [defaultSupabaseProjectRef, supabaseProjectRef])
const [foreignProjectId, setForeignProjectId] = useState<string | undefined>(
defaultForeignProjectId
)
useEffect(() => {
if (defaultForeignProjectId !== undefined && foreignProjectId === undefined)
setForeignProjectId(defaultForeignProjectId)
}, [defaultForeignProjectId, foreignProjectId])
// create a flat array of foreign project ids. ie, ["prj_MlkO6AiLG5ofS9ojKrkS3PhhlY3f", ..]
const flatInstalledConnectionsIds = new Set(installedConnections.map((x) => x.foreign_project_id))
const selectedSupabaseProject = supabaseProjectRef
? supabaseProjects.find((x) => x.ref?.toLowerCase() === supabaseProjectRef?.toLowerCase())
: undefined
const selectedForeignProject = foreignProjectId
? foreignProjects.find((x) => x.id?.toLowerCase() === foreignProjectId?.toLowerCase())
: undefined
function onCreateConnections() {
const projectDetails = selectedForeignProject
if (!selectedForeignProject?.id) return console.error('No Foreign project ID set')
if (!selectedSupabaseProject?.ref) return console.error('No Supabase project ref set')
const alreadyInstalled = flatInstalledConnectionsIds.has(foreignProjectId ?? '')
if (alreadyInstalled) {
return toast.error(
`Unable to connect to ${selectedForeignProject.name}: Selected repository already has an installed connection to a project`
)
}
_onCreateConnections({
organizationIntegrationId: organizationIntegrationId!,
connection: {
foreign_project_id: selectedForeignProject?.id,
supabase_project_ref: selectedSupabaseProject?.ref,
integration_id: '0',
metadata: {
...projectDetails,
},
},
orgSlug: selectedOrganization?.slug,
new: {
installation_id: selectedForeignProject.installation_id!,
project_ref: selectedSupabaseProject.ref,
repository_id: Number(selectedForeignProject.id),
},
})
}
const Panel = ({ children, className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
'flex-1 min-w-0 flex flex-col grow gap-6 px-5 mx-auto w-full justify-center items-center',
className
)}
{...props}
>
{children}
</div>
)
}
const noSupabaseProjects = supabaseProjects.length === 0
const noForeignProjects = foreignProjects.length === 0
const missingEntity = noSupabaseProjects ? 'Supabase' : mode
const oppositeMissingEntity = noSupabaseProjects ? mode : 'Supabase'
return (
<div className="flex flex-col gap-4">
<div className="relative border rounded-lg p-12 bg shadow">
<div
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' }}
/>
{loadingForeignProjects || loadingSupabaseProjects ? (
<div className="w-1/2 mx-auto space-y-2 py-4">
<p className="text-sm text-foreground text-center">Loading projects</p>
<ShimmerLine active />
</div>
) : showNoEntitiesState && (noSupabaseProjects || noForeignProjects) ? (
<div className="text-center">
<h5 className="text-foreground">No {missingEntity} Projects found</h5>
<p className="text-foreground-light text-sm">
You will need to create a {missingEntity} Project to link to a {oppositeMissingEntity}{' '}
Project.
<br />
You can skip this and create a Project Connection later.
</p>
</div>
) : (
<div className="flex justify-center gap-0 w-full relative">
<Panel>
<div className="bg-white shadow border rounded p-1 w-12 h-12 flex justify-center items-center">
<img src={`${BASE_PATH}/img/supabase-logo.svg`} alt="Supabase" className="w-6" />
</div>
<Popover_Shadcn_
open={supabaseProjectsComboBoxOpen}
onOpenChange={setSupabaseProjectsComboboxOpen}
>
<PopoverTrigger_Shadcn_ asChild>
<Button
ref={supabaseProjectsComboBoxRef}
type="default"
block
disabled={defaultSupabaseProjectRef !== undefined || loadingSupabaseProjects}
loading={loadingSupabaseProjects}
className="justify-start h-[34px]"
icon={
<div className="bg-white shadow border rounded p-1 w-6 h-6 flex justify-center items-center">
<img
src={`${BASE_PATH}/img/supabase-logo.svg`}
alt="Supabase"
className="w-4"
/>
</div>
}
iconRight={
defaultSupabaseProjectRef === undefined ? (
<span className="grow flex justify-end">
<ChevronDown />
</span>
) : null
}
>
{selectedSupabaseProject
? selectedSupabaseProject.name
: 'Choose Supabase Project'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0 !w-72"
side="bottom"
align="center"
style={{ width: supabaseProjectsComboBoxRef.current?.offsetWidth }}
>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search for a project" />
<CommandList_Shadcn_ className="!max-h-[170px]">
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{supabaseProjects.map((project, i) => {
return (
<CommandItem_Shadcn_
value={`${project.name.replaceAll('"', '')}-${i}`}
key={project.ref}
className="flex gap-2 items-center"
onSelect={() => {
if (project.ref) setSupabaseProjectRef(project.ref)
setSupabaseProjectsComboboxOpen(false)
}}
>
<div className="bg-white shadow border rounded p-1 w-6 h-6 flex justify-center items-center">
<img
src={`${BASE_PATH}/img/supabase-logo.svg`}
alt="Supabase"
className="w-4"
/>
</div>
<span>{project.name}</span>
</CommandItem_Shadcn_>
)
})}
{supabaseProjects.length === 0 && (
<p className="text-xs text-foreground-lighter px-2 py-2">
No projects found in this organization
</p>
)}
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="flex gap-2 items-center cursor-pointer"
onClick={() => router.push(`/new/${selectedOrganization?.slug}`)}
onSelect={() => router.push(`/new/${selectedOrganization?.slug}`)}
>
<PlusIcon size={16} />
<span>Create a new project</span>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</Panel>
<div className="border border-foreground-lighter h-px w-8 border-dashed self-end mb-4" />
<Panel>
<div className="bg-black shadow rounded p-1 w-12 h-12 flex justify-center items-center">
{integrationIcon}
</div>
<Popover_Shadcn_
open={foreignProjectsComboBoxOpen}
onOpenChange={setForeignProjectsComboboxOpen}
>
<PopoverTrigger_Shadcn_ asChild>
<Button
ref={foreignProjectsComboBoxRef}
type="default"
block
disabled={loadingForeignProjects}
loading={loadingForeignProjects}
className="justify-start h-[34px]"
icon={
<div>
{selectedForeignProject
? getForeignProjectIcon?.(selectedForeignProject) ?? integrationIcon
: integrationIcon}
</div>
}
iconRight={
<span className="grow flex justify-end">
<ChevronDown />
</span>
}
>
{(selectedForeignProject && selectedForeignProject.name) ?? choosePrompt}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0 !w-72"
side="bottom"
align="center"
style={{ width: foreignProjectsComboBoxRef.current?.offsetWidth }}
>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search for a project" />
<CommandList_Shadcn_ className="!max-h-[170px]">
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{foreignProjects.map((project, i) => {
return (
<CommandItem_Shadcn_
key={project.id}
value={`${project.name.replaceAll('"', '')}-${i}`}
className="flex gap-2 items-center"
onSelect={() => {
if (project.id) setForeignProjectId(project.id)
setForeignProjectsComboboxOpen(false)
}}
>
<div>{getForeignProjectIcon?.(project) ?? integrationIcon}</div>
<span className="truncate" title={project.name}>
{project.name}
</span>
</CommandItem_Shadcn_>
)
})}
{foreignProjects.length === 0 && (
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
)}
</CommandGroup_Shadcn_>
{mode === 'GitHub' && (
<>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="flex gap-2 items-center cursor-pointer"
onSelect={() => openInstallGitHubIntegrationWindow('install')}
>
<PlusIcon size={16} />
Add GitHub Repositories
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
</>
)}
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</Panel>
</div>
)}
</div>
<div className="flex w-full justify-end gap-2">
{onSkip !== undefined && (
<Button
size="medium"
type="default"
onClick={() => {
onSkip()
}}
>
Skip
</Button>
)}
<Button
size="medium"
className="self-end"
onClick={onCreateConnections}
loading={isLoading}
disabled={
// data loading states
loadingForeignProjects ||
loadingSupabaseProjects ||
isLoading ||
// check whether both project types are not undefined
!selectedSupabaseProject ||
!selectedForeignProject
}
>
Connect project
</Button>
</div>
</div>
)
}
export default ProjectLinker