org-selector.tsx 6.1 KB

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