ProjectDropdown.tsx205 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import { Box, Plus } from 'lucide-react' |
| 3 | import Link from 'next/link' |
| 4 | import { useRouter } from 'next/router' |
| 5 | import type { ComponentProps } from 'react' |
| 6 | import { useState } from 'react' |
| 7 | import { Button, CommandGroup, CommandItem } from 'ui' |
| 8 | import { ShimmeringLoader } from 'ui-patterns' |
| 9 | |
| 10 | import { AppLayoutDropdownTriggerButton } from './AppLayoutDropdown' |
| 11 | import { sanitizeRoute } from './ProjectDropdown.utils' |
| 12 | import { ProjectRowLink } from './ProjectRowLink' |
| 13 | import { useEmbeddedCloseHandler } from './useEmbeddedCloseHandler' |
| 14 | import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' |
| 15 | import PartnerIcon from '@/components/ui/PartnerIcon' |
| 16 | import { getManagedByFromOrganizationPartner } from '@/data/organizations/managed-by-utils' |
| 17 | import type { OrgProject } from '@/data/projects/org-projects-infinite-query' |
| 18 | import { useProjectDetailQuery } from '@/data/projects/project-detail-query' |
| 19 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 20 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 21 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 22 | import { IS_PLATFORM } from '@/lib/constants' |
| 23 | import type { ManagedBy } from '@/lib/constants/infrastructure' |
| 24 | import { useTrack } from '@/lib/telemetry/track' |
| 25 | |
| 26 | // --- Sub-components --- |
| 27 | |
| 28 | interface ProjectDropdownNewProjectActionsProps { |
| 29 | organizationSlug: string | undefined |
| 30 | embedded: boolean |
| 31 | onClose: () => void |
| 32 | onNavigate: (href: string) => void |
| 33 | } |
| 34 | |
| 35 | function ProjectDropdownNewProjectActions({ |
| 36 | organizationSlug, |
| 37 | embedded, |
| 38 | onClose, |
| 39 | onNavigate, |
| 40 | }: ProjectDropdownNewProjectActionsProps) { |
| 41 | const href = `/new/${organizationSlug}` |
| 42 | |
| 43 | if (embedded) { |
| 44 | return ( |
| 45 | <Button type="default" block size="small" asChild icon={<Plus size={14} strokeWidth={1.5} />}> |
| 46 | <Link |
| 47 | href={href} |
| 48 | onClick={onClose} |
| 49 | className="text-xs text-foreground-light hover:text-foreground" |
| 50 | > |
| 51 | New project |
| 52 | </Link> |
| 53 | </Button> |
| 54 | ) |
| 55 | } |
| 56 | |
| 57 | return ( |
| 58 | <CommandGroup> |
| 59 | <CommandItem |
| 60 | className="cursor-pointer w-full" |
| 61 | onSelect={() => { |
| 62 | onClose() |
| 63 | onNavigate(href) |
| 64 | }} |
| 65 | onClick={onClose} |
| 66 | > |
| 67 | <Link href={href} onClick={onClose} className="w-full flex items-center gap-2"> |
| 68 | <Plus size={14} strokeWidth={1.5} /> |
| 69 | <p>New project</p> |
| 70 | </Link> |
| 71 | </CommandItem> |
| 72 | </CommandGroup> |
| 73 | ) |
| 74 | } |
| 75 | |
| 76 | const ProjectDropdownNonPlatformView = ({ projectName }: { projectName: string }) => { |
| 77 | return <div className="text-sm px-3 py-1">{projectName}</div> |
| 78 | } |
| 79 | |
| 80 | interface ProjectDropdownPlatformViewProps { |
| 81 | projectRef: string | undefined |
| 82 | projectName: string |
| 83 | projectManagedBy?: ManagedBy |
| 84 | selectorProps: Omit< |
| 85 | ComponentProps<typeof OrganizationProjectSelector>, |
| 86 | 'renderTrigger' | 'embedded' |
| 87 | > |
| 88 | } |
| 89 | |
| 90 | function ProjectDropdownPlatformView({ |
| 91 | projectRef, |
| 92 | projectName, |
| 93 | projectManagedBy, |
| 94 | selectorProps, |
| 95 | }: ProjectDropdownPlatformViewProps) { |
| 96 | return ( |
| 97 | <div className="flex items-center shrink-0"> |
| 98 | <Link href={`/project/${projectRef}`} className="flex items-center gap-2 shrink-0 text-sm"> |
| 99 | <Box size={14} strokeWidth={1.5} className="text-foreground-lighter" /> |
| 100 | <span title={projectName} className="text-foreground max-w-32 lg:max-w-64 truncate"> |
| 101 | {projectName} |
| 102 | </span> |
| 103 | {projectManagedBy && <PartnerIcon organization={{ managed_by: projectManagedBy }} />} |
| 104 | </Link> |
| 105 | |
| 106 | <OrganizationProjectSelector |
| 107 | {...selectorProps} |
| 108 | renderTrigger={() => <AppLayoutDropdownTriggerButton className="shrink-0" />} |
| 109 | /> |
| 110 | </div> |
| 111 | ) |
| 112 | } |
| 113 | |
| 114 | // --- Main component --- |
| 115 | |
| 116 | interface ProjectDropdownProps { |
| 117 | embedded?: boolean |
| 118 | className?: string |
| 119 | onClose?: () => void |
| 120 | } |
| 121 | |
| 122 | export const ProjectDropdown = ({ |
| 123 | embedded = false, |
| 124 | className, |
| 125 | onClose, |
| 126 | }: ProjectDropdownProps = {}) => { |
| 127 | const router = useRouter() |
| 128 | const { ref } = useParams() |
| 129 | const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery() |
| 130 | const { data: selectedOrganization } = useSelectedOrganizationQuery() |
| 131 | |
| 132 | const isBranch = project?.parentRef !== project?.ref |
| 133 | const { data: parentProject, isPending: isLoadingParentProject } = useProjectDetailQuery( |
| 134 | { ref: project?.parent_project_ref }, |
| 135 | { enabled: isBranch } |
| 136 | ) |
| 137 | const selectedProject = parentProject ?? project |
| 138 | |
| 139 | const projectCreationEnabled = useIsFeatureEnabled('projects:create') |
| 140 | const track = useTrack() |
| 141 | |
| 142 | const [open, setOpen] = useState(false) |
| 143 | const close = useEmbeddedCloseHandler(embedded, onClose, setOpen) |
| 144 | const selectedProjectManagedBy = selectedProject?.integration_source |
| 145 | ? getManagedByFromOrganizationPartner(undefined, selectedProject.integration_source) |
| 146 | : selectedOrganization?.billing_partner |
| 147 | ? selectedOrganization.managed_by |
| 148 | : undefined |
| 149 | |
| 150 | if (isLoadingProject || (isBranch && isLoadingParentProject) || !selectedProject) { |
| 151 | if (!embedded) return <ShimmeringLoader className="p-2 md:mr-2 md:w-[90px]" /> |
| 152 | } |
| 153 | |
| 154 | const handleSetOpen = embedded |
| 155 | ? (_value: boolean) => onClose?.() |
| 156 | : (next: boolean) => { |
| 157 | if (next) track('header_project_dropdown_opened') |
| 158 | setOpen(next) |
| 159 | } |
| 160 | |
| 161 | const selectorProps = { |
| 162 | open, |
| 163 | setOpen: handleSetOpen, |
| 164 | selectedRef: ref, |
| 165 | onSelect: (project: { ref: string }) => { |
| 166 | const sanitizedRoute = sanitizeRoute(router.route, router.query) |
| 167 | const href = sanitizedRoute?.replace('[ref]', project.ref) ?? `/project/${project.ref}` |
| 168 | close() |
| 169 | router.push(href) |
| 170 | }, |
| 171 | renderRow: (project: Pick<OrgProject, 'ref' | 'name' | 'status' | 'integration_source'>) => ( |
| 172 | <ProjectRowLink |
| 173 | project={project} |
| 174 | selectedRef={ref} |
| 175 | route={router.route} |
| 176 | routerQueries={router.query} |
| 177 | /> |
| 178 | ), |
| 179 | renderActions: (_setOpen: (value: boolean) => void, options?: { embedded?: boolean }) => |
| 180 | projectCreationEnabled ? ( |
| 181 | <ProjectDropdownNewProjectActions |
| 182 | organizationSlug={selectedOrganization?.slug} |
| 183 | embedded={options?.embedded ?? false} |
| 184 | onClose={close} |
| 185 | onNavigate={(href) => router.push(href)} |
| 186 | /> |
| 187 | ) : null, |
| 188 | } |
| 189 | |
| 190 | if (embedded) |
| 191 | return ( |
| 192 | <OrganizationProjectSelector {...selectorProps} embedded className={className} fetchOnMount /> |
| 193 | ) |
| 194 | |
| 195 | return IS_PLATFORM ? ( |
| 196 | <ProjectDropdownPlatformView |
| 197 | projectRef={project?.ref} |
| 198 | projectName={selectedProject?.name ?? ''} |
| 199 | projectManagedBy={selectedProjectManagedBy} |
| 200 | selectorProps={selectorProps} |
| 201 | /> |
| 202 | ) : ( |
| 203 | <ProjectDropdownNonPlatformView projectName={selectedProject?.name ?? ''} /> |
| 204 | ) |
| 205 | } |