org-selector.tsx182 lines · main
1import { ChevronDown } from 'lucide-react'
2import Link from 'next/link'
3import { parseAsString, useQueryState } from 'nuqs'
4import { useMemo, useState } from 'react'
5import { Badge, Button, Card, CardHeader, CardTitle, Input } from 'ui'
6import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
7
8import { ButtonTooltip } from './ButtonTooltip'
9import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
10import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
11import type { Organization } from '@/types'
12
13export interface ProjectClaimChooseOrgProps {
14 onSelect: (orgSlug: string) => void
15 maxOrgsToShow?: number
16 canCreateNewOrg: boolean
17}
18
19const OrganizationCard = ({
20 org,
21 onSelect,
22}: {
23 org: Organization
24 onSelect: (orgSlug: string) => void
25}) => {
26 const isFreePlan = org.plan?.id === 'free'
27 const { data: membersExceededLimit, isSuccess } = useFreeProjectLimitCheckQuery(
28 { slug: org.slug },
29 { enabled: isFreePlan }
30 )
31 const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0
32 const freePlanWithExceedingLimits = isFreePlan && hasMembersExceedingFreeTierLimit
33
34 return (
35 <Card
36 key={org.id}
37 className="hover:bg-surface-200 rounded-none first:rounded-t-lg last:rounded-b-lg -mb-px"
38 >
39 <CardHeader className="flex flex-row justify-between border-none space-y-0 space-x-2">
40 <CardTitle className="flex items-center gap-2 min-w-0 flex-1">
41 <span className="truncate min-w-0" title={org.name}>
42 {org.name}
43 </span>
44 <Badge className="shrink-0">{org.plan?.name}</Badge>
45 </CardTitle>
46 <ButtonTooltip
47 tooltip={{
48 content: {
49 text:
50 isSuccess && freePlanWithExceedingLimits ? (
51 <div className="space-y-3 w-96 p-2">
52 <p className="text-sm leading-normal">
53 The following members have reached their maximum limits for the number of
54 active free plan projects within organizations where they are an administrator
55 or owner:
56 </p>
57 <ul className="pl-5 list-disc">
58 {membersExceededLimit.map((member, idx: number) => (
59 <li key={`member-${idx}`}>
60 {member.username || member.primary_email} (Limit:{' '}
61 {member.free_project_limit} free projects)
62 </li>
63 ))}
64 </ul>
65 <p className="text-sm leading-normal">
66 These members will need to either delete, pause, or upgrade one or more of
67 these projects before you're able to create a free project within this
68 organization.
69 </p>
70 </div>
71 ) : undefined,
72 },
73 }}
74 size="small"
75 onClick={() => {
76 onSelect(org.slug)
77 }}
78 className="shrink-0"
79 disabled={isSuccess && freePlanWithExceedingLimits}
80 >
81 Choose
82 </ButtonTooltip>
83 </CardHeader>
84 </Card>
85 )
86}
87
88export function OrganizationSelector({
89 onSelect,
90 maxOrgsToShow = 5,
91 canCreateNewOrg,
92}: ProjectClaimChooseOrgProps) {
93 const {
94 data: organizations = [],
95 isPending: isLoadingOrgs,
96 isSuccess: isSuccessOrgs,
97 isError: isErrorOrgs,
98 } = useOrganizationsQuery()
99
100 const [search, setSearch] = useQueryState(
101 'org',
102 parseAsString.withDefault('').withOptions({ clearOnDefault: true })
103 )
104 const [showAll, setShowAll] = useState(false)
105
106 const filteredOrgs = useMemo(() => {
107 if (!search) {
108 return showAll ? organizations : organizations.slice(0, maxOrgsToShow)
109 }
110 return organizations.filter((org) => org.name.toLowerCase().includes(search.toLowerCase()))
111 }, [organizations, search, showAll, maxOrgsToShow])
112
113 const searchParams = new URLSearchParams(location.search)
114 let pathname = location.pathname
115 const basePath = process.env.NEXT_PUBLIC_BASE_PATH
116 if (basePath) {
117 pathname = pathname.replace(basePath, '')
118 }
119
120 searchParams.set('returnTo', pathname)
121
122 const onSelectOrg = (orgSlug: string) => {
123 onSelect(orgSlug)
124 setSearch('')
125 }
126
127 return (
128 <div className="w-full flex flex-col gap-y-4">
129 {isLoadingOrgs ? (
130 <ShimmeringLoader />
131 ) : isErrorOrgs ? (
132 <div>Error</div>
133 ) : isSuccessOrgs && organizations.length === 0 ? (
134 <span className="text-sm text-foreground-light">
135 It seems you don't have any organizations yet.
136 </span>
137 ) : (
138 <>
139 <Input
140 type="text"
141 value={search}
142 onChange={(e) => setSearch(e.target.value)}
143 placeholder="Search..."
144 />
145 <div>
146 {filteredOrgs.length === 0 && (
147 <div className="text-center text-foreground-light py-6">No organizations found.</div>
148 )}
149 {filteredOrgs.map((org) => (
150 <OrganizationCard key={org.id} org={org} onSelect={onSelectOrg} />
151 ))}
152 {organizations.length > maxOrgsToShow && !showAll && !search && (
153 <div className="flex justify-center py-2">
154 <Button
155 icon={<ChevronDown className="w-4 h-4" />}
156 size="tiny"
157 onClick={() => {
158 setSearch('')
159 setShowAll(true)
160 }}
161 type="default"
162 >
163 Show all organizations
164 </Button>
165 </div>
166 )}
167 </div>
168 </>
169 )}
170 {canCreateNewOrg && (
171 <Card className="flex items-center justify-between border-dashed pr-6">
172 <CardHeader className="border-none">
173 <CardTitle>Need a new organization?</CardTitle>
174 </CardHeader>
175 <Button size="small" className="" asChild type="default">
176 <Link href={`/new?${searchParams.toString()}`}>New Organization</Link>
177 </Button>
178 </Card>
179 )}
180 </div>
181 )
182}